diff --git a/app/build.gradle b/app/build.gradle index b853f26112..0356771551 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -511,7 +511,7 @@ dependencies { implementation libs.google.play.services.maps implementation libs.google.play.services.auth - implementation libs.bundles.exoplayer + implementation libs.bundles.media3 implementation libs.conscrypt.android implementation libs.signal.aesgcmprovider diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64a2fcdb51..ad0e20c978 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1134,9 +1134,11 @@ + diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/RetryableInitAudioSink.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/RetryableInitAudioSink.kt index d06ce153ae..699437d537 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/RetryableInitAudioSink.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/RetryableInitAudioSink.kt @@ -1,9 +1,11 @@ package org.thoughtcrime.securesms.components.voice import android.content.Context -import com.google.android.exoplayer2.audio.AudioCapabilities -import com.google.android.exoplayer2.audio.AudioSink -import com.google.android.exoplayer2.audio.DefaultAudioSink +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.AudioCapabilities +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink import org.signal.core.util.logging.Log import java.nio.ByteBuffer @@ -12,6 +14,7 @@ import java.nio.ByteBuffer * It does eventually recover, but it needs to be given ample opportunity to. * This class wraps the final DefaultAudioSink to provide exactly that functionality. */ +@OptIn(UnstableApi::class) class RetryableInitAudioSink( context: Context, enableFloatOutput: Boolean, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java deleted file mode 100644 index ff341da3cd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java +++ /dev/null @@ -1,565 +0,0 @@ -package org.thoughtcrime.securesms.components.voice; - -import android.content.ComponentName; -import android.net.Uri; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; -import android.support.v4.media.MediaBrowserCompat; -import android.support.v4.media.MediaMetadataCompat; -import android.support.v4.media.session.MediaControllerCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.recipients.LiveRecipient; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.DefaultValueLiveData; -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; - -import java.util.Objects; -import java.util.Optional; - -/** - * Encapsulates control of voice note playback from an Activity component. - *

- * This class assumes that it will be created within the scope of Activity#onCreate - *

- * The workhorse of this repository is the ProgressEventHandler, which will supply a - * steady stream of update events to the set callback. - */ -public class VoiceNoteMediaController implements DefaultLifecycleObserver { - - public static final String EXTRA_THREAD_ID = "voice.note.thread_id"; - public static final String EXTRA_MESSAGE_ID = "voice.note.message_id"; - public static final String EXTRA_PROGRESS = "voice.note.playhead"; - public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single"; - - private static final String TAG = Log.tag(VoiceNoteMediaController.class); - - private MediaBrowserCompat mediaBrowser; - private FragmentActivity activity; - private ProgressEventHandler progressEventHandler; - private MutableLiveData voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE); - private LiveData> voiceNotePlayerViewState; - private VoiceNoteProximityWakeLockManager voiceNoteProximityWakeLockManager; - private boolean isMediaBrowserCreationPostponed; - - private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback(); - - public VoiceNoteMediaController(@NonNull FragmentActivity activity) { - this(activity, false); - } - - public VoiceNoteMediaController(@NonNull FragmentActivity activity, boolean postponeMediaBrowserCreation) { - this.activity = activity; - this.isMediaBrowserCreationPostponed = postponeMediaBrowserCreation; - - activity.getLifecycle().addObserver(this); - - voiceNotePlayerViewState = Transformations.switchMap(voiceNotePlaybackState, playbackState -> { - if (playbackState.getClipType() instanceof VoiceNotePlaybackState.ClipType.Message) { - VoiceNotePlaybackState.ClipType.Message message = (VoiceNotePlaybackState.ClipType.Message) playbackState.getClipType(); - LiveRecipient sender = Recipient.live(message.getSenderId()); - LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId()); - LiveData name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(), - threadRecipient.getLiveDataResolved(), - (s, t) -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null)); - - return Transformations.map(name, displayName -> Optional.of( - new VoiceNotePlayerView.State( - playbackState.getUri(), - message.getMessageId(), - message.getThreadId(), - !playbackState.isPlaying(), - message.getSenderId(), - message.getThreadRecipientId(), - message.getMessagePosition(), - message.getTimestamp(), - displayName, - playbackState.getPlayheadPositionMillis(), - playbackState.getTrackDuration(), - playbackState.getSpeed()))); - } else { - return new DefaultValueLiveData<>(Optional.empty()); - } - }); - } - - public void ensureMediaBrowser() { - if (mediaBrowser != null) { - return; - } - - mediaBrowser = new MediaBrowserCompat(activity, - new ComponentName(activity, VoiceNotePlaybackService.class), - new ConnectionCallback(), - null); - } - - public LiveData getVoiceNotePlaybackState() { - return voiceNotePlaybackState; - } - - public LiveData> getVoiceNotePlayerViewState() { - return voiceNotePlayerViewState; - } - - public void finishPostpone() { - isMediaBrowserCreationPostponed = false; - if (activity != null && mediaBrowser == null && activity.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { - ensureMediaBrowser(); - mediaBrowser.disconnect(); - mediaBrowser.connect(); - } - } - - @Override - public void onResume(@NonNull LifecycleOwner owner) { - if (mediaBrowser == null && isMediaBrowserCreationPostponed) { - return; - } - - ensureMediaBrowser(); - mediaBrowser.disconnect(); - mediaBrowser.connect(); - } - - @Override - public void onPause(@NonNull LifecycleOwner owner) { - clearProgressEventHandler(); - - if (MediaControllerCompat.getMediaController(activity) != null) { - MediaControllerCompat.getMediaController(activity).unregisterCallback(mediaControllerCompatCallback); - } - - if (mediaBrowser != null) { - mediaBrowser.disconnect(); - } - } - - @Override - public void onDestroy(@NonNull LifecycleOwner owner) { - if (voiceNoteProximityWakeLockManager != null) { - voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease(); - voiceNoteProximityWakeLockManager.unregisterFromLifecycle(); - voiceNoteProximityWakeLockManager = null; - } - - activity.getLifecycle().removeObserver(this); - activity = null; - } - - private static boolean isPlayerActive(@NonNull PlaybackStateCompat playbackStateCompat) { - return playbackStateCompat.getState() == PlaybackStateCompat.STATE_BUFFERING || - playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING; - } - - private static boolean isPlayerPaused(@NonNull PlaybackStateCompat playbackStateCompat) { - return playbackStateCompat.getState() == PlaybackStateCompat.STATE_PAUSED; - } - - private static boolean isPlayerStopped(@NonNull PlaybackStateCompat playbackStateCompat) { - return playbackStateCompat.getState() <= PlaybackStateCompat.STATE_STOPPED; - } - - private @Nullable MediaControllerCompat getMediaController() { - if (activity != null) { - return MediaControllerCompat.getMediaController(activity); - } else { - return null; - } - } - - - public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) { - startPlayback(audioSlideUri, messageId, -1, progress, false); - } - - public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) { - startPlayback(audioSlideUri, messageId, -1, progress, true); - } - - public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) { - startPlayback(draftUri, -1, threadId, progress, true); - } - - /** - * Tells the Media service to begin playback of a given audio slide. If the audio - * slide is currently playing, we jump to the desired position and then begin playback. - * - * @param audioSlideUri The Uri of the desired audio slide - * @param messageId The Message id of the given audio slide - * @param progress The desired progress % to seek to. - * @param singlePlayback The player will only play back the specified Uri, and not build a playlist. - */ - private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) { - if (getMediaController() == null) { - Log.w(TAG, "Called startPlayback before controller was set. (" + getActivityName() + ")"); - return; - } - - if (isCurrentTrack(audioSlideUri)) { - long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION); - - getMediaController().getTransportControls().seekTo((long) (duration * progress)); - getMediaController().getTransportControls().play(); - } else { - Bundle extras = new Bundle(); - extras.putLong(EXTRA_MESSAGE_ID, messageId); - extras.putLong(EXTRA_THREAD_ID, threadId); - extras.putDouble(EXTRA_PROGRESS, progress); - extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback); - - getMediaController().getTransportControls().playFromUri(audioSlideUri, extras); - } - } - - /** - * Tells the Media service to resume playback of a given audio slide. If the audio slide is not - * currently paused, playback will be started from the beginning. - * - * @param audioSlideUri The Uri of the desired audio slide - * @param messageId The Message id of the given audio slide - */ - public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) { - if (getMediaController() == null) { - Log.w(TAG, "Called resumePlayback before controller was set. (" + getActivityName() + ")"); - return; - } - - if (isCurrentTrack(audioSlideUri)) { - getMediaController().getTransportControls().play(); - } else { - Bundle extras = new Bundle(); - extras.putLong(EXTRA_MESSAGE_ID, messageId); - extras.putLong(EXTRA_THREAD_ID, -1L); - extras.putDouble(EXTRA_PROGRESS, 0.0); - extras.putBoolean(EXTRA_PLAY_SINGLE, true); - - getMediaController().getTransportControls().playFromUri(audioSlideUri, extras); - } - } - - /** - * Pauses playback if the given audio slide is playing. - * - * @param audioSlideUri The Uri of the audio slide to pause. - */ - public void pausePlayback(@NonNull Uri audioSlideUri) { - if (getMediaController() == null) { - Log.w(TAG, "Called pausePlayback(uri) before controller was set. (" + getActivityName() + ")"); - return; - } - - if (isCurrentTrack(audioSlideUri)) { - getMediaController().getTransportControls().pause(); - } - } - - /** - * Pauses playback regardless of which audio slide is playing. - */ - public void pausePlayback() { - if (getMediaController() == null) { - Log.w(TAG, "Called pausePlayback before controller was set. (" + getActivityName() + ")"); - return; - } - - getMediaController().getTransportControls().pause(); - } - - /** - * Seeks to a given position if th given audio slide is playing. This call - * is ignored if the given audio slide is not currently playing. - * - * @param audioSlideUri The Uri of the audio slide to seek. - * @param progress The progress percentage to seek to. - */ - public void seekToPosition(@NonNull Uri audioSlideUri, double progress) { - if (getMediaController() == null) { - Log.w(TAG, "Called seekToPosition before controller was set. (" + getActivityName() + ")"); - return; - } - - if (isCurrentTrack(audioSlideUri)) { - long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION); - - getMediaController().getTransportControls().pause(); - getMediaController().getTransportControls().seekTo((long) (duration * progress)); - getMediaController().getTransportControls().play(); - } - } - - /** - * Stops playback if the given audio slide is playing - * - * @param audioSlideUri The Uri of the audio slide to stop - */ - public void stopPlaybackAndReset(@NonNull Uri audioSlideUri) { - if (getMediaController() == null) { - Log.w(TAG, "Called stopPlaybackAndReset before controller was set. (" + getActivityName() + ")"); - return; - } - - if (isCurrentTrack(audioSlideUri)) { - getMediaController().getTransportControls().stop(); - } - } - - public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) { - if (getMediaController() == null) { - Log.w(TAG, "Called setPlaybackSpeed before controller was set. (" + getActivityName() + ")"); - return; - } - - if (isCurrentTrack(audioSlideUri)) { - Bundle bundle = new Bundle(); - bundle.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, playbackSpeed); - - getMediaController().sendCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, bundle, null); - } - } - - private boolean isCurrentTrack(@NonNull Uri uri) { - if (getMediaController() == null) { - Log.w(TAG, "Called isCurrentTrack before controller was set. (" + getActivityName() + ")"); - return false; - } - - MediaMetadataCompat metadataCompat = getMediaController().getMetadata(); - - return metadataCompat != null && Objects.equals(metadataCompat.getDescription().getMediaUri(), uri); - } - - private void notifyProgressEventHandler() { - if (getMediaController() == null) { - Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (" + getActivityName() + ")"); - return; - } - - if (progressEventHandler == null && activity != null) { - progressEventHandler = new ProgressEventHandler(getMediaController(), voiceNotePlaybackState); - progressEventHandler.sendEmptyMessage(0); - } - } - - private void clearProgressEventHandler() { - if (progressEventHandler != null) { - progressEventHandler = null; - } - } - - private @NonNull String getActivityName() { - if (activity == null) { - return "Activity is null"; - } else { - return activity.getLocalClassName(); - } - } - - private final class ConnectionCallback extends MediaBrowserCompat.ConnectionCallback { - @Override - public void onConnected() { - MediaSessionCompat.Token token = mediaBrowser.getSessionToken(); - MediaControllerCompat mediaController = new MediaControllerCompat(activity, token); - - MediaControllerCompat.setMediaController(activity, mediaController); - - MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata(); - if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) { - VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null); - - if (newState != null) { - voiceNotePlaybackState.postValue(newState); - } else { - voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE); - } - } - - cleanUpOldProximityWakeLockManager(); - voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController); - - mediaController.registerCallback(mediaControllerCompatCallback); - - mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState()); - } - - @Override - public void onConnectionSuspended() { - Log.d(TAG, "Voice note MediaBrowser connection suspended."); - cleanUpOldProximityWakeLockManager(); - } - - @Override - public void onConnectionFailed() { - Log.d(TAG, "Voice note MediaBrowser connection failed."); - cleanUpOldProximityWakeLockManager(); - } - - private void cleanUpOldProximityWakeLockManager() { - if (voiceNoteProximityWakeLockManager != null) { - Log.d(TAG, "Session reconnected, cleaning up old wake lock manager"); - voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease(); - voiceNoteProximityWakeLockManager.unregisterFromLifecycle(); - voiceNoteProximityWakeLockManager = null; - } - } - } - - private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) { - return mediaMetadataCompat != null && - mediaMetadataCompat.getDescription() != null && - mediaMetadataCompat.getDescription().getMediaUri() != null; - } - - private static @Nullable VoiceNotePlaybackState extractStateFromMetadata(@NonNull MediaControllerCompat mediaController, - @NonNull MediaMetadataCompat mediaMetadataCompat, - @Nullable VoiceNotePlaybackState previousState) - { - Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri()); - boolean autoReset = Objects.equals(mediaUri, VoiceNoteMediaItemFactory.NEXT_URI) || Objects.equals(mediaUri, VoiceNoteMediaItemFactory.END_URI); - long position = mediaController.getPlaybackState().getPosition(); - long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION); - Bundle extras = mediaController.getExtras(); - float speed = extras != null ? extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) : 1f; - - if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) { - if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) { - position = previousState.getPlayheadPositionMillis(); - } - - if (duration <= 0 && previousState.getTrackDuration() > 0) { - duration = previousState.getTrackDuration(); - } - } - - if (duration > 0 && position >= 0 && position <= duration) { - return new VoiceNotePlaybackState(mediaUri, - position, - duration, - autoReset, - speed, - isPlayerActive(mediaController.getPlaybackState()), - getClipType(mediaMetadataCompat.getBundle())); - } else { - return null; - } - } - - private static @Nullable VoiceNotePlaybackState constructPlaybackState(@NonNull MediaControllerCompat mediaController, - @Nullable VoiceNotePlaybackState previousState) - { - MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata(); - if (isPlayerActive(mediaController.getPlaybackState()) && - canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) - { - return extractStateFromMetadata(mediaController, mediaMetadataCompat, previousState); - } else if (isPlayerPaused(mediaController.getPlaybackState()) && - mediaMetadataCompat != null) - { - long position = mediaController.getPlaybackState().getPosition(); - long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION); - - if (previousState != null && position < duration) { - return previousState.asPaused(); - } else { - return VoiceNotePlaybackState.NONE; - } - } else { - return VoiceNotePlaybackState.NONE; - } - } - - private static @NonNull VoiceNotePlaybackState.ClipType getClipType(@Nullable Bundle mediaExtras) { - long messageId = -1L; - RecipientId senderId = RecipientId.UNKNOWN; - long messagePosition = -1L; - long threadId = -1L; - RecipientId threadRecipientId = RecipientId.UNKNOWN; - long timestamp = -1L; - - if (mediaExtras != null) { - messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L); - messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L); - threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L); - timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L); - - String serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID); - if (serializedSenderId != null) { - senderId = RecipientId.from(serializedSenderId); - } - - String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID); - if (serializedThreadRecipientId != null) { - threadRecipientId = RecipientId.from(serializedThreadRecipientId); - } - } - - if (messageId != -1L) { - return new VoiceNotePlaybackState.ClipType.Message(messageId, - senderId, - threadRecipientId, - messagePosition, - threadId, - timestamp); - } else { - return VoiceNotePlaybackState.ClipType.Draft.INSTANCE; - } - } - - private static class ProgressEventHandler extends Handler { - - private final MediaControllerCompat mediaController; - private final MutableLiveData voiceNotePlaybackState; - - private ProgressEventHandler(@NonNull MediaControllerCompat mediaController, - @NonNull MutableLiveData voiceNotePlaybackState) - { - super(Looper.getMainLooper()); - - this.mediaController = mediaController; - this.voiceNotePlaybackState = voiceNotePlaybackState; - } - - @Override - public void handleMessage(@NonNull Message msg) { - VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue()); - - if (newPlaybackState != null) { - voiceNotePlaybackState.postValue(newPlaybackState); - } - - if (isPlayerActive(mediaController.getPlaybackState())) { - sendEmptyMessageDelayed(0, 50); - } - } - } - - private final class MediaControllerCompatCallback extends MediaControllerCompat.Callback { - @Override - public void onPlaybackStateChanged(PlaybackStateCompat state) { - if (isPlayerActive(state)) { - notifyProgressEventHandler(); - } else { - clearProgressEventHandler(); - - if (isPlayerStopped(state)) { - voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE); - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.kt new file mode 100644 index 0000000000..5daeb41fc6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.kt @@ -0,0 +1,450 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.voice + +import android.content.ComponentName +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.RequestMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.concurrent.addTo +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.DefaultValueLiveData +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil +import java.util.Optional + +/** + * This is a lifecycle-aware wrapper for the [MediaController]. + * Its main responsibilities are broadcasting playback state through [LiveData], + * and resolving metadata values for a audio clip's URI into a [MediaItem] that media3 can understand. + */ +class VoiceNoteMediaController(val activity: FragmentActivity, private var postponeMediaControllerCreation: Boolean) : DefaultLifecycleObserver { + + val voiceNotePlaybackState = MutableLiveData(VoiceNotePlaybackState.NONE) + val voiceNotePlayerViewState: LiveData> + private val disposables: LifecycleDisposable = LifecycleDisposable() + private var mediaControllerProperty: MediaController? = null + private lateinit var voiceNoteProximityWakeLockManager: VoiceNoteProximityWakeLockManager + private var progressEventHandler: ProgressEventHandler? = null + private var queuedPlayback: PlaybackItem? = null + + init { + activity.lifecycle.addObserver(this) + + voiceNotePlayerViewState = voiceNotePlaybackState.switchMap { (uri, playheadPositionMillis, trackDuration, _, speed, isPlaying, clipType): VoiceNotePlaybackState -> + if (clipType is VoiceNotePlaybackState.ClipType.Message) { + val (messageId, senderId, threadRecipientId, messagePosition, threadId, timestamp) = clipType + val sender = Recipient.live(senderId) + val threadRecipient = Recipient.live(threadRecipientId) + val name = LiveDataUtil.combineLatest( + sender.liveDataResolved, + threadRecipient.liveDataResolved + ) { s: Recipient, t: Recipient -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null) } + + return@switchMap name.map> { displayName: String -> + Optional.of( + VoiceNotePlayerView.State( + uri, + messageId, + threadId, + !isPlaying, + senderId, + threadRecipientId, + messagePosition, + timestamp, + displayName, + playheadPositionMillis, + trackDuration, + speed + ) + ) + } + } else { + return@switchMap DefaultValueLiveData>(Optional.empty()) + } + } + } + + override fun onStart(owner: LifecycleOwner) { + super.onStart(owner) + if (mediaControllerProperty == null && postponeMediaControllerCreation) { + Log.i(TAG, "Postponing media controller creation. (${activity.localClassName}})") + return + } + + createMediaControllerAsync() + } + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + + progressEventHandler?.sendEmptyMessage(0) + } + + override fun onPause(owner: LifecycleOwner) { + clearProgressEventHandler() + super.onPause(owner) + } + + override fun onStop(owner: LifecycleOwner) { + super.onStop(owner) + mediaControllerProperty?.release() + mediaControllerProperty = null + } + + override fun onDestroy(owner: LifecycleOwner) { + voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease() + voiceNoteProximityWakeLockManager.unregisterFromLifecycle() + activity.lifecycle.removeObserver(this) + super.onDestroy(owner) + } + + fun finishPostpone() { + if (mediaControllerProperty == null && postponeMediaControllerCreation && activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + Log.i(TAG, "Finishing postponed media controller creation. (${activity.localClassName}})") + createMediaControllerAsync() + } else { + Log.w(TAG, "Could not finish postponed media controller creation! (${activity.localClassName}})") + } + } + + private fun createMediaControllerAsync() { + val voiceNotePlaybackServiceSessionToken = SessionToken(activity, ComponentName(activity, VoiceNotePlaybackService::class.java)) + val mediaControllerBuilder = MediaController.Builder(activity, voiceNotePlaybackServiceSessionToken) + Observable.fromFuture(mediaControllerBuilder.buildAsync()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + initializeMediaController(it) + } + .addTo(disposables) + } + + private fun initializeMediaController(uninitializedMediaController: MediaController) { + postponeMediaControllerCreation = false + + voiceNoteProximityWakeLockManager = VoiceNoteProximityWakeLockManager(activity, uninitializedMediaController) + uninitializedMediaController.addListener(PlaybackStateListener()) + Log.d(TAG, "MediaController successfully initialized. (${activity.localClassName})") + mediaControllerProperty = uninitializedMediaController + queuedPlayback?.let { startPlayback(it) } + queuedPlayback = null + notifyProgressEventHandler() + } + + private fun notifyProgressEventHandler() { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (${activity.localClassName})") + return + } + if (progressEventHandler == null) { + progressEventHandler = ProgressEventHandler(mediaController, voiceNotePlaybackState) + } + progressEventHandler?.sendEmptyMessage(0) + } + + private fun clearProgressEventHandler() { + progressEventHandler = null + } + + fun startConsecutivePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) { + startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, false)) + } + + fun startSinglePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) { + startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, true)) + } + + fun startSinglePlaybackForDraft(draftUri: Uri, threadId: Long, progress: Double) { + startPlayback(PlaybackItem(draftUri, -1, threadId, progress, true)) + } + + fun resumePlayback(audioSlideUri: Uri, messageId: Long) { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Tried to resume playback before the media controller was ready.") + return + } + if (isCurrentTrack(audioSlideUri)) { + mediaController.play() + } else { + startSinglePlayback(audioSlideUri, messageId, 0.0) + } + } + + fun pausePlayback(audioSlideUri: Uri) { + if (isCurrentTrack(audioSlideUri)) { + pausePlayback() + } else { + Log.i(TAG, "Tried to pause $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}") + } + } + + fun pausePlayback() { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Tried to pause playback before the media controller was ready.") + return + } + mediaController.pause() + } + + fun seekToPosition(audioSlideUri: Uri, progress: Double) { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Tried to seekToPosition before the media controller was ready.") + return + } + if (isCurrentTrack(audioSlideUri)) { + mediaController.seekTo((mediaController.duration * progress).toLong()) + } else { + Log.i(TAG, "Tried to seek $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}") + } + } + + fun stopPlaybackAndReset(audioSlideUri: Uri) { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Tried to stopPlaybackAndReset before the media controller was ready.") + return + } + if (isCurrentTrack(audioSlideUri)) { + mediaController.stop() + } else { + Log.i(TAG, "Tried to stop $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}") + } + } + + fun setPlaybackSpeed(audioSlideUri: Uri, playbackSpeed: Float) { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Tried to set playback speed before the media controller was ready.") + return + } + + if (isCurrentTrack(audioSlideUri)) { + mediaController.setPlaybackSpeed(playbackSpeed) + } else { + Log.i(TAG, "Tried to set playback speed of $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}") + } + } + + /** + * Tells the Media service to begin playback of a given audio slide. If the audio + * slide is currently playing, we jump to the desired position and then begin playback. + * + * @param audioSlideUri The Uri of the desired audio slide + * @param messageId The Message id of the given audio slide + * @param progress The desired progress % to seek to. + * @param singlePlayback The player will only play back the specified Uri, and not build a playlist. + */ + private fun startPlayback(playbackItem: PlaybackItem) { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Tried to start playback before the media controller was ready.") + queuedPlayback = playbackItem + return + } + + if (isCurrentTrack(playbackItem.audioSlideUri)) { + val duration: Long = mediaController.duration + mediaController.seekTo((duration * playbackItem.progress).toLong()) + mediaController.play() + } else { + val extras = bundleOf( + EXTRA_MESSAGE_ID to playbackItem.messageId, + EXTRA_THREAD_ID to playbackItem.threadId, + EXTRA_PROGRESS to playbackItem.progress, + EXTRA_PLAY_SINGLE to playbackItem.singlePlayback + ) + val requestMetadata = RequestMetadata.Builder().setMediaUri(playbackItem.audioSlideUri).setExtras(extras).build() + if (playbackItem.singlePlayback) { + mediaController.clearMediaItems() + } + val mediaItem = MediaItem.Builder() + .setUri(playbackItem.audioSlideUri) + .setRequestMetadata(requestMetadata).build() + mediaController.addMediaItem(mediaItem) + mediaController.play() + } + } + + private fun isCurrentTrack(uri: Uri): Boolean { + val mediaController = mediaControllerProperty + if (mediaController == null) { + Log.w(TAG, "Called isCurrentTrack before media controller was set. (${activity.localClassName}})") + return false + } + return uri == getCurrentlyPlayingUri() + } + + private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + + private fun getCurrentlyPlayingUri(): Uri? = mediaControllerProperty?.currentMediaItem?.requestMetadata?.mediaUri + + inner class PlaybackStateListener : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + if (!isActivityResumed()) { + return + } + + if (player.isPlaying) { + notifyProgressEventHandler() + } else { + clearProgressEventHandler() + if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) { + voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE) + } + } + } + } + } + + private class ProgressEventHandler( + private val mediaController: MediaController, + private val voiceNotePlaybackState: MutableLiveData + ) : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + val newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.value) + voiceNotePlaybackState.postValue(newPlaybackState) + val playerActive = mediaController.isPlaying + if (playerActive) { + sendEmptyMessageDelayed(0, 50) + } + } + } + + companion object { + private val TAG = Log.tag(VoiceNoteMediaController::class.java) + + var EXTRA_THREAD_ID = "voice.note.thread_id" + var EXTRA_MESSAGE_ID = "voice.note.message_id" + var EXTRA_PROGRESS = "voice.note.playhead" + var EXTRA_PLAY_SINGLE = "voice.note.play.single" + + @JvmStatic + private fun constructPlaybackState( + mediaController: MediaController, + previousState: VoiceNotePlaybackState? + ): VoiceNotePlaybackState { + val mediaUri = mediaController.currentMediaItem?.requestMetadata?.mediaUri + return if (mediaController.isPlaying && + mediaUri != null + ) { + extractStateFromMetadata(mediaController, mediaUri, previousState) + } else if (mediaController.playbackState == Player.STATE_READY && !mediaController.playWhenReady) { + val position = mediaController.currentPosition + val duration = mediaController.contentDuration + if (previousState != null && position < duration) { + previousState.asPaused() + } else { + VoiceNotePlaybackState.NONE + } + } else { + VoiceNotePlaybackState.NONE + } + } + + @JvmStatic + private fun extractStateFromMetadata( + mediaController: MediaController, + mediaUri: Uri, + previousState: VoiceNotePlaybackState? + ): VoiceNotePlaybackState { + val speed = mediaController.playbackParameters.speed + var duration = mediaController.contentDuration + val mediaMetadata = mediaController.mediaMetadata + var position = mediaController.currentPosition + val autoReset = mediaUri == VoiceNoteMediaItemFactory.NEXT_URI || mediaUri == VoiceNoteMediaItemFactory.END_URI + if (previousState != null && mediaUri == previousState.uri) { + if (position < 0 && previousState.playheadPositionMillis >= 0) { + position = previousState.playheadPositionMillis + } + if (duration <= 0 && previousState.trackDuration > 0) { + duration = previousState.trackDuration + } + } + return if (duration > 0 && position >= 0 && position <= duration) { + VoiceNotePlaybackState( + mediaUri, + position, + duration, + autoReset, + speed, + mediaController.isPlaying, + getClipType(mediaMetadata.extras) + ) + } else { + VoiceNotePlaybackState.NONE + } + } + + @JvmStatic + private fun getClipType(mediaExtras: Bundle?): VoiceNotePlaybackState.ClipType { + var messageId = -1L + var senderId = RecipientId.UNKNOWN + var messagePosition = -1L + var threadId = -1L + var threadRecipientId = RecipientId.UNKNOWN + var timestamp = -1L + if (mediaExtras != null) { + messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L) + messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L) + threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L) + timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L) + val serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID) + if (serializedSenderId != null) { + senderId = RecipientId.from(serializedSenderId) + } + val serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID) + if (serializedThreadRecipientId != null) { + threadRecipientId = RecipientId.from(serializedThreadRecipientId) + } + } + return if (messageId != -1L) { + VoiceNotePlaybackState.ClipType.Message( + messageId, + senderId!!, + threadRecipientId!!, + messagePosition, + threadId, + timestamp + ) + } else { + VoiceNotePlaybackState.ClipType.Draft + } + } + } + + /** + * Holder class that contains everything one might need to begin voice note playback. Useful for queueing up items to play when the media controller is being initialized. + */ + data class PlaybackItem(val audioSlideUri: Uri, val messageId: Long, val threadId: Long, val progress: Double, val singlePlayback: Boolean) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaItemFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaItemFactory.java index deef397855..3b2aa58a6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaItemFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaItemFactory.java @@ -3,14 +3,12 @@ package org.thoughtcrime.securesms.components.voice; import android.content.Context; import android.net.Uri; import android.os.Bundle; -import android.support.v4.media.MediaDescriptionCompat; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; - -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.MediaMetadata; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaMetadata; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -144,22 +142,21 @@ class VoiceNoteMediaItemFactory { } return new MediaItem.Builder() - .setUri(audioUri) - .setMediaMetadata( - new MediaMetadata.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setExtras(extras) - .build() - ) - .setTag( - new MediaDescriptionCompat.Builder() - .setMediaUri(audioUri) - .setTitle(title) - .setSubtitle(subtitle) - .setExtras(extras) - .build()) - .build(); + .setUri(audioUri) + .setMediaMetadata( + new MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setExtras(extras) + .build() + ) + .setRequestMetadata( + new MediaItem.RequestMetadata.Builder() + .setMediaUri(audioUri) + .setExtras(extras) + .build() + ) + .build(); } public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) { @@ -191,18 +188,16 @@ class VoiceNoteMediaItemFactory { } private static MediaItem cloneMediaItem(MediaItem source, String mediaId, Uri uri) { - MediaDescriptionCompat description = source.playbackProperties != null ? (MediaDescriptionCompat) source.playbackProperties.tag : null; + Bundle requestExtras = source.requestMetadata.extras; return source.buildUpon() .setMediaId(mediaId) .setUri(uri) - .setTag( - description != null ? - new MediaDescriptionCompat.Builder() + .setMediaMetadata(source.mediaMetadata) + .setRequestMetadata( + new MediaItem.RequestMetadata.Builder() .setMediaUri(uri) - .setTitle(description.getTitle()) - .setSubtitle(description.getSubtitle()) - .setExtras(description.getExtras()) - .build() : null) + .setExtras(requestExtras) + .build()) .build(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaNotificationProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaNotificationProvider.kt new file mode 100644 index 0000000000..8386958cc9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaNotificationProvider.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.voice + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.media3.common.C +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import com.google.common.collect.ImmutableList +import org.signal.core.util.PendingIntentFlags.cancelCurrent +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.AvatarUtil +import java.util.Arrays + +/** + * This handles all of the notification and playback APIs for playing back a voice note. + * It integrates, using [androidx.media.app.NotificationCompat.MediaStyle], with the system's media controls. + */ +@OptIn(markerClass = [UnstableApi::class]) +class VoiceNoteMediaNotificationProvider(val context: Context) : MediaNotification.Provider { + private val notificationChannel: String = NotificationChannels.getInstance().VOICE_NOTES + private var cachedRecipientId: RecipientId? = null + private var cachedBitmap: Bitmap? = null + + override fun createNotification(mediaSession: MediaSession, customLayout: ImmutableList, actionFactory: MediaNotification.ActionFactory, onNotificationChangedCallback: MediaNotification.Provider.Callback): MediaNotification { + val player = mediaSession.player + val builder = NotificationCompat.Builder(context, notificationChannel) + .setSmallIcon(R.drawable.ic_notification) + .setColorized(true) + if (player.isCommandAvailable(Player.COMMAND_GET_METADATA)) { + val metadata: MediaMetadata = player.mediaMetadata + builder + .setContentTitle(metadata.title) + .setContentText(metadata.subtitle) + } + val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() + val compactViewIndices: IntArray = addNotificationActions( + mediaSession, + getMediaButtons( + player.availableCommands, + customLayout, + player.playWhenReady && + player.playbackState != Player.STATE_ENDED + ), + builder, + actionFactory + ) + mediaStyle.setShowActionsInCompactView(*compactViewIndices) + + if (player.isCommandAvailable(Player.COMMAND_STOP)) { + mediaStyle.setCancelButtonIntent( + actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong()) + ) + } + val extras = mediaSession.player.mediaMetadata.extras + + if (extras != null) { + var color = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR).toInt() + if (color == 0) { + color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor() + } + builder.color = color + + val pendingIntent = createCurrentContentIntent(extras) + builder.setContentIntent(pendingIntent) + } else { + Log.w(TAG, "Could not populate notification: request metadata extras were null.") + } + builder.setDeleteIntent( + actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong()) + ) + .setOnlyAlertOnce(true) + .setStyle(mediaStyle) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(false) + addLargeIcon(builder, extras, onNotificationChangedCallback) + + return MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build()) + } + + /** + * Borrowed from [DefaultMediaNotificationProvider] + */ + private fun addNotificationActions( + mediaSession: MediaSession?, + mediaButtons: ImmutableList, + builder: NotificationCompat.Builder, + actionFactory: MediaNotification.ActionFactory + ): IntArray { + var compactViewIndices = IntArray(3) + val defaultCompactViewIndices = IntArray(3) + Arrays.fill(compactViewIndices, C.INDEX_UNSET) + Arrays.fill(defaultCompactViewIndices, C.INDEX_UNSET) + var compactViewCommandCount = 0 + for (i in mediaButtons.indices) { + val commandButton = mediaButtons[i] + if (commandButton.sessionCommand != null) { + builder.addAction( + actionFactory.createCustomActionFromCustomCommandButton(mediaSession!!, commandButton) + ) + } else { + Assertions.checkState(commandButton.playerCommand != Player.COMMAND_INVALID) + builder.addAction( + actionFactory.createMediaAction( + mediaSession!!, + IconCompat.createWithResource(context, commandButton.iconResId), + commandButton.displayName, + commandButton.playerCommand + ) + ) + } + if (compactViewCommandCount == 3) { + continue + } + val compactViewIndex = commandButton.extras.getInt( + DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, + C.INDEX_UNSET + ) + if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.size) { + compactViewCommandCount++ + compactViewIndices[compactViewIndex] = i + } else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS || + commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM + ) { + defaultCompactViewIndices[0] = i + } else if (commandButton.playerCommand == Player.COMMAND_PLAY_PAUSE) { + defaultCompactViewIndices[1] = i + } else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT || + commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM + ) { + defaultCompactViewIndices[2] = i + } + } + if (compactViewCommandCount == 0) { + // If there is no custom configuration we use the seekPrev (if any), play/pause (if any), + // seekNext (if any) action in compact view. + var indexInCompactViewIndices = 0 + for (i in defaultCompactViewIndices.indices) { + if (defaultCompactViewIndices[i] == C.INDEX_UNSET) { + continue + } + compactViewIndices[indexInCompactViewIndices] = defaultCompactViewIndices[i] + indexInCompactViewIndices++ + } + } + for (i in compactViewIndices.indices) { + if (compactViewIndices[i] == C.INDEX_UNSET) { + compactViewIndices = compactViewIndices.copyOf(i) + break + } + } + return compactViewIndices + } + + /** + * Borrowed from [DefaultMediaNotificationProvider] + */ + private fun getMediaButtons( + playerCommands: Player.Commands, + customLayout: ImmutableList, + showPauseButton: Boolean + ): ImmutableList { + val commandButtons = ImmutableList.Builder() + if (playerCommands.containsAny(Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) { + val commandButtonExtras = Bundle() + commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET) + commandButtons.add( + CommandButton.Builder() + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .setIconResId(R.drawable.exo_icon_rewind) + .setDisplayName( + context.getString(R.string.media3_controls_seek_to_previous_description) + ) + .setExtras(commandButtonExtras) + .build() + ) + } + if (playerCommands.contains(Player.COMMAND_PLAY_PAUSE)) { + val commandButtonExtras = Bundle() + commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET) + commandButtons.add( + CommandButton.Builder() + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setIconResId( + if (showPauseButton) R.drawable.exo_notification_pause else R.drawable.exo_notification_play + ) + .setExtras(commandButtonExtras) + .setDisplayName( + if (showPauseButton) context.getString(R.string.media3_controls_pause_description) else context.getString(R.string.media3_controls_play_description) + ) + .build() + ) + } + if (playerCommands.containsAny(Player.COMMAND_STOP)) { + val commandButtonExtras = Bundle() + commandButtons.add( + CommandButton.Builder() + .setPlayerCommand(Player.COMMAND_STOP) + .setIconResId(R.drawable.exo_notification_stop) + .setExtras(commandButtonExtras) + .setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description)) + .build() + ) + } + for (i in customLayout.indices) { + val button = customLayout[i] + if (button.sessionCommand != null && + button.sessionCommand!!.commandCode == SessionCommand.COMMAND_CODE_CUSTOM + ) { + commandButtons.add(button) + } + } + return commandButtons.build() + } + + private fun createCurrentContentIntent(extras: Bundle): PendingIntent? { + val serializedRecipientId = extras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID) ?: return null + val recipientId = RecipientId.from(serializedRecipientId) + val startingPosition = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION) + val threadId = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID) + + val conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId) + .withStartingPosition(startingPosition.toInt()) + .build() + + conversationActivity.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + return PendingIntent.getActivity( + context, + 0, + conversationActivity, + cancelCurrent() + ) + } + + /** + * This will either fetch a cached bitmap and add it to the builder immediately, + * OR it will set a callback to update the notification once the bitmap is fetched by [AvatarUtil] + */ + private fun addLargeIcon(builder: NotificationCompat.Builder, extras: Bundle?, callback: MediaNotification.Provider.Callback) { + if (extras == null || !SignalStore.settings().messageNotificationsPrivacy.isDisplayContact) { + cachedBitmap = null + cachedRecipientId = null + return + } + + val serializedRecipientId: String = extras.getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID) ?: return + + val currentRecipientId = RecipientId.from(serializedRecipientId) + + if (currentRecipientId == cachedRecipientId && cachedBitmap != null) { + builder.setLargeIcon(cachedBitmap) + } else { + cachedRecipientId = currentRecipientId + SignalExecutors.BOUNDED.execute { + try { + cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId!!)) + builder.setLargeIcon(cachedBitmap) + callback.onNotificationChanged(MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build())) + } catch (e: Exception) { + cachedBitmap = null + } + } + } + } + + /** + * We do not currently support any custom commands in the notification area. + */ + override fun handleCustomCommand(session: MediaSession, action: String, extras: Bundle): Boolean { + throw UnsupportedOperationException("Custom command handler for Notification is unused.") + } + + companion object { + private const val NOW_PLAYING_NOTIFICATION_ID = 32221 + private const val TAG = "VoiceNoteMediaNotificationProvider" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java deleted file mode 100644 index d9189b827a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.thoughtcrime.securesms.components.voice; - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.support.v4.media.session.MediaControllerCompat; -import android.support.v4.media.session.MediaSessionCompat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ui.PlayerNotificationManager; - -import org.signal.core.util.PendingIntentFlags; -import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.conversation.ConversationIntents; -import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.AvatarUtil; - -import java.util.Objects; - -class VoiceNoteNotificationManager { - - private static final short NOW_PLAYING_NOTIFICATION_ID = 32221; - - private final Context context; - private final MediaControllerCompat controller; - private final PlayerNotificationManager notificationManager; - - VoiceNoteNotificationManager(@NonNull Context context, - @NonNull MediaSessionCompat.Token token, - @NonNull PlayerNotificationManager.NotificationListener listener) - { - this.context = context; - controller = new MediaControllerCompat(context, token); - notificationManager = new PlayerNotificationManager.Builder(context, NOW_PLAYING_NOTIFICATION_ID, NotificationChannels.getInstance().VOICE_NOTES) - .setChannelNameResourceId(R.string.NotificationChannel_voice_notes) - .setMediaDescriptionAdapter(new DescriptionAdapter()) - .setNotificationListener(listener) - .build(); - - notificationManager.setMediaSessionToken(token); - notificationManager.setSmallIcon(R.drawable.ic_notification); - notificationManager.setColorized(true); - notificationManager.setUseFastForwardAction(false); - notificationManager.setUseRewindAction(false); - notificationManager.setUseStopAction(true); - } - - public void hideNotification() { - notificationManager.setPlayer(null); - } - - public void showNotification(@NonNull Player player) { - notificationManager.setPlayer(player); - } - - private final class DescriptionAdapter implements PlayerNotificationManager.MediaDescriptionAdapter { - - private RecipientId cachedRecipientId; - private Bitmap cachedBitmap; - - @Override - public String getCurrentContentTitle(Player player) { - if (hasMetadata()) { - return Objects.toString(controller.getMetadata().getDescription().getTitle(), null); - } else { - return null; - } - } - - @Override - public @Nullable PendingIntent createCurrentContentIntent(Player player) { - if (!hasMetadata()) { - return null; - } - - String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID); - if (serializedRecipientId == null) { - return null; - } - - RecipientId recipientId = RecipientId.from(serializedRecipientId); - int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION); - long threadId = controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID); - - int color = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR); - - if (color == 0) { - color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor(); - } - - notificationManager.setColor(color); - - Intent conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId) - .withStartingPosition(startingPosition) - .build(); - - conversationActivity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - return PendingIntent.getActivity(context, - 0, - conversationActivity, - PendingIntentFlags.cancelCurrent()); - } - - @Override - public String getCurrentContentText(Player player) { - if (hasMetadata()) { - return Objects.toString(controller.getMetadata().getDescription().getSubtitle(), null); - } else { - return null; - } - } - - @Override - public @Nullable Bitmap getCurrentLargeIcon(Player player, PlayerNotificationManager.BitmapCallback callback) { - if (!hasMetadata() || !SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) { - cachedBitmap = null; - cachedRecipientId = null; - return null; - } - - String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID); - if (serializedRecipientId == null) { - return null; - } - - RecipientId currentRecipientId = RecipientId.from(serializedRecipientId); - - if (Objects.equals(currentRecipientId, cachedRecipientId) && cachedBitmap != null) { - return cachedBitmap; - } else { - cachedRecipientId = currentRecipientId; - SignalExecutors.BOUNDED.execute(() -> { - try { - cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId)); - callback.onBitmap(cachedBitmap); - } catch (Exception e) { - cachedBitmap = null; - } - }); - - return null; - } - } - - private boolean hasMetadata() { - return controller.getMetadata() != null && controller.getMetadata().getDescription() != null; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt deleted file mode 100644 index 4a600a80a9..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackController.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.thoughtcrime.securesms.components.voice - -import android.media.AudioManager -import android.os.Bundle -import android.os.ResultReceiver -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.PlaybackParameters -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.util.Util -import org.signal.core.util.logging.Log - -class VoiceNotePlaybackController( - private val player: ExoPlayer, - private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters -) : MediaSessionConnector.CommandReceiver { - - companion object { - private val TAG = Log.tag(VoiceNoteMediaController::class.java) - } - - override fun onCommand(p: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { - Log.d(TAG, "[onCommand] Received player command $command (extras? ${extras != null})") - - if (command == VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) { - val speed = extras?.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) ?: 1f - player.playbackParameters = PlaybackParameters(speed) - voiceNotePlaybackParameters.setSpeed(speed) - return true - } else if (command == VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) { - val newStreamType: Int = extras?.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) ?: AudioManager.STREAM_MUSIC - - val currentStreamType = Util.getStreamTypeForAudioUsage(player.audioAttributes.usage) - if (newStreamType != currentStreamType) { - val attributes = when (newStreamType) { - AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build() - AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build() - else -> throw AssertionError() - } - - player.playWhenReady = false - player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC) - if (newStreamType == AudioManager.STREAM_VOICE_CALL) { - player.playWhenReady = true - } - } - return true - } - return false - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackParameters.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackParameters.java deleted file mode 100644 index fa30209d2d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackParameters.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.thoughtcrime.securesms.components.voice; - -import android.os.Bundle; -import android.support.v4.media.session.MediaSessionCompat; - -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.PlaybackParameters; - -public final class VoiceNotePlaybackParameters { - - private final MediaSessionCompat mediaSessionCompat; - - VoiceNotePlaybackParameters(@NonNull MediaSessionCompat mediaSessionCompat) { - this.mediaSessionCompat = mediaSessionCompat; - } - - @NonNull PlaybackParameters getParameters() { - float speed = getSpeed(); - return new PlaybackParameters(speed); - } - - void setSpeed(float speed) { - Bundle extras = new Bundle(); - extras.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, speed); - - mediaSessionCompat.setExtras(extras); - } - - private float getSpeed() { - Bundle extras = mediaSessionCompat.getController().getExtras(); - - if (extras == null) { - return 1f; - } else { - return extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java deleted file mode 100644 index 4fe7dc6316..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java +++ /dev/null @@ -1,298 +0,0 @@ -package org.thoughtcrime.securesms.components.voice; - -import android.content.Context; -import android.net.Uri; -import android.os.Bundle; -import android.os.ResultReceiver; -import android.support.v4.media.session.PlaybackStateCompat; -import android.widget.Toast; - -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import com.annimon.stream.Stream; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.MediaMetadata; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; - -import org.signal.core.util.ThreadUtil; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.NoSuchMessageException; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.util.MessageRecordUtil; -import org.thoughtcrime.securesms.util.Util; -import org.signal.core.util.concurrent.SimpleTask; - -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.stream.Collectors; - -/** - * ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI - */ -final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackPreparer { - - private static final String TAG = Log.tag(VoiceNotePlaybackPreparer.class); - private static final Executor EXECUTOR = Executors.newSingleThreadExecutor(); - private static final long LIMIT = 5; - - private final Context context; - private final Player player; - private final VoiceNotePlaybackParameters voiceNotePlaybackParameters; - - private boolean canLoadMore; - private Uri latestUri = Uri.EMPTY; - - VoiceNotePlaybackPreparer(@NonNull Context context, - @NonNull Player player, - @NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters) - { - this.context = context; - this.player = player; - this.voiceNotePlaybackParameters = voiceNotePlaybackParameters; - } - - @Override - public long getSupportedPrepareActions() { - return PlaybackStateCompat.ACTION_PLAY_FROM_URI; - } - - @Override - public void onPrepare(boolean playWhenReady) { - Log.w(TAG, "Requested playback from IDLE state. Ignoring."); - } - - @Override - public void onPrepareFromMediaId(@NonNull String mediaId, boolean playWhenReady, @Nullable Bundle extras) { - throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromMediaId"); - } - - @Override - public void onPrepareFromSearch(@NonNull String query, boolean playWhenReady, @Nullable Bundle extras) { - throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromSearch"); - } - - @Override - public void onPrepareFromUri(@NonNull Uri uri, boolean playWhenReady, @Nullable Bundle extras) { - Log.d(TAG, "onPrepareFromUri: " + uri); - if (extras == null) { - return; - } - - long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID); - long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID); - double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0); - boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false); - - canLoadMore = false; - latestUri = uri; - - SimpleTask.run(EXECUTOR, - () -> { - if (singlePlayback) { - if (messageId != -1) { - return loadMediaItemsForSinglePlayback(messageId); - } else { - return loadMediaItemsForDraftPlayback(threadId, uri); - } - } else { - return loadMediaItemsForConsecutivePlayback(messageId); - } - }, - mediaItems -> { - player.clearMediaItems(); - - if (Util.hasItems(mediaItems) && Objects.equals(latestUri, uri)) { - applyDescriptionsToQueue(mediaItems); - - int window = Math.max(0, indexOfPlayerMediaItemByUri(uri)); - - player.addListener(new Player.Listener() { - @Override - public void onTimelineChanged(@NonNull Timeline timeline, int reason) { - if (timeline.getWindowCount() >= window) { - player.setPlayWhenReady(false); - player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters()); - player.seekTo(window, (long) (player.getDuration() * progress)); - player.setPlayWhenReady(true); - player.removeListener(this); - } - } - }); - - player.prepare(); - canLoadMore = !singlePlayback; - } else if (Objects.equals(latestUri, uri)) { - Log.w(TAG, "Requested playback but no voice notes could be found."); - ThreadUtil.postToMain(() -> { - Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT) - .show(); - }); - } - }); - } - - @MainThread - private void applyDescriptionsToQueue(@NonNull List mediaItems) { - for (MediaItem mediaItem : mediaItems) { - final MediaItem.LocalConfiguration playbackProperties = mediaItem.playbackProperties; - if (playbackProperties == null) { - continue; - } - int holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri); - MediaItem next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem); - int currentIndex = player.getCurrentWindowIndex(); - - if (holderIndex != -1) { - if (currentIndex != holderIndex) { - player.removeMediaItem(holderIndex); - player.addMediaItem(holderIndex, mediaItem); - } - - if (currentIndex != holderIndex + 1) { - if (player.getMediaItemCount() > 1) { - player.removeMediaItem(holderIndex + 1); - } - - player.addMediaItem(holderIndex + 1, next); - } - } else { - int insertLocation = indexAfter(mediaItem); - - player.addMediaItem(insertLocation, next); - player.addMediaItem(insertLocation, mediaItem); - } - } - - int itemsCount = player.getMediaItemCount(); - if (itemsCount > 0) { - int lastIndex = itemsCount - 1; - MediaItem last = player.getMediaItemAt(lastIndex); - - if (last.playbackProperties != null && - Objects.equals(last.playbackProperties.uri, VoiceNoteMediaItemFactory.NEXT_URI)) - { - player.removeMediaItem(lastIndex); - - if (player.getMediaItemCount() > 1) { - MediaItem end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last); - - player.addMediaItem(lastIndex, end); - } - } - } - } - - private int indexOfPlayerMediaItemByUri(@NonNull Uri uri) { - for (int i = 0; i < player.getMediaItemCount(); i++) { - final MediaItem.LocalConfiguration playbackProperties = player.getMediaItemAt(i).playbackProperties; - if (playbackProperties != null && playbackProperties.uri.equals(uri)) { - return i; - } - } - return -1; - } - - private int indexAfter(@NonNull MediaItem target) { - int size = player.getMediaItemCount(); - long targetMessageId = target.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID); - for (int i = 0; i < size; i++) { - MediaMetadata mediaMetadata = player.getMediaItemAt(i).mediaMetadata; - long messageId = mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID); - - if (messageId > targetMessageId) { - return i; - } - } - return size; - } - - public void loadMoreVoiceNotes() { - if (!canLoadMore) { - return; - } - - MediaItem currentMediaItem = player.getCurrentMediaItem(); - if (currentMediaItem == null) { - return; - } - - long messageId = currentMediaItem.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID); - - SimpleTask.run(EXECUTOR, - () -> loadMediaItemsForConsecutivePlayback(messageId), - mediaItems -> { - if (Util.hasItems(mediaItems) && canLoadMore) { - applyDescriptionsToQueue(mediaItems); - } - }); - } - - private @NonNull List loadMediaItemsForSinglePlayback(long messageId) { - try { - MessageRecord messageRecord = SignalDatabase.messages() - .getMessageRecord(messageId); - - if (!MessageRecordUtil.hasAudio(messageRecord)) { - Log.w(TAG, "Message does not contain audio."); - return Collections.emptyList(); - } - - MediaItem mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord); - if (mediaItem == null) { - return Collections.emptyList(); - } else { - return Collections.singletonList(mediaItem); - } - } catch (NoSuchMessageException e) { - Log.w(TAG, "Could not find message.", e); - return Collections.emptyList(); - } - } - - private @NonNull List loadMediaItemsForDraftPlayback(long threadId, @NonNull Uri draftUri) { - return Collections - .singletonList(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri)); - } - - @WorkerThread - private @NonNull List loadMediaItemsForConsecutivePlayback(long messageId) { - try { - List recordsAfter = SignalDatabase.messages().getMessagesAfterVoiceNoteInclusive(messageId, LIMIT); - - return buildFilteredMessageRecordList(recordsAfter).stream() - .map(record -> VoiceNoteMediaItemFactory - .buildMediaItem(context, record)) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - } catch (NoSuchMessageException e) { - Log.w(TAG, "Could not find message.", e); - return Collections.emptyList(); - } - } - - private static @NonNull List buildFilteredMessageRecordList(@NonNull List recordsAfter) { - return Stream.of(recordsAfter) - .takeWhile(MessageRecordUtil::hasAudio) - .toList(); - } - - @SuppressWarnings("deprecation") - @Override - public boolean onCommand(@NonNull Player player, - @NonNull String command, - @Nullable Bundle extras, - @Nullable ResultReceiver cb) - { - return false; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java index a22bfbadd6..fdae172e13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -1,31 +1,33 @@ package org.thoughtcrime.securesms.components.voice; -import android.app.Notification; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.media.AudioManager; import android.net.Uri; +import android.os.Build; import android.os.Bundle; -import android.os.Process; -import android.support.v4.media.MediaBrowserCompat; -import android.support.v4.media.session.MediaControllerCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.media.MediaBrowserServiceCompat; +import androidx.annotation.OptIn; +import androidx.core.content.ContextCompat; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Player; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.session.MediaController; +import androidx.media3.session.MediaSession; +import androidx.media3.session.MediaSessionService; +import androidx.media3.session.SessionToken; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.audio.AudioAttributes; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.ui.PlayerNotificationManager; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; @@ -33,105 +35,69 @@ import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.ForegroundServiceUtil; import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob; import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; -import org.thoughtcrime.securesms.jobs.UnableToStartException; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.service.KeyCachingService; import java.util.Collections; -import java.util.List; /** * Android Service responsible for playback of voice notes. */ -public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { +@OptIn(markerClass = UnstableApi.class) +public class VoiceNotePlaybackService extends MediaSessionService { public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed"; public static final String ACTION_SET_AUDIO_STREAM = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.set_audio_stream"; private static final String TAG = Log.tag(VoiceNotePlaybackService.class); + private static final String SESSION_ID = "VoiceNotePlayback"; private static final String EMPTY_ROOT_ID = "empty-root-id"; private static final int LOAD_MORE_THRESHOLD = 2; - private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY | - PlaybackStateCompat.ACTION_PAUSE | - PlaybackStateCompat.ACTION_SEEK_TO | - PlaybackStateCompat.ACTION_STOP | - PlaybackStateCompat.ACTION_PLAY_PAUSE; - - private MediaSessionCompat mediaSession; - private MediaSessionConnector mediaSessionConnector; - private VoiceNotePlayer player; - private BecomingNoisyReceiver becomingNoisyReceiver; - private KeyClearedReceiver keyClearedReceiver; - private VoiceNoteNotificationManager voiceNoteNotificationManager; - private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer; - private boolean isForegroundService; - private VoiceNotePlaybackParameters voiceNotePlaybackParameters; + private MediaSession mediaSession; + private VoiceNotePlayer player; + private KeyClearedReceiver keyClearedReceiver; + private VoiceNotePlayerCallback voiceNotePlayerCallback; @Override public void onCreate() { super.onCreate(); - - mediaSession = new MediaSessionCompat(this, TAG); - voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession); - mediaSessionConnector = new MediaSessionConnector(mediaSession); - becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken()); - keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getSessionToken()); - player = new VoiceNotePlayer(this); - voiceNoteNotificationManager = new VoiceNoteNotificationManager(this, - mediaSession.getSessionToken(), - new VoiceNoteNotificationManagerListener()); - voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, voiceNotePlaybackParameters); - + player = new VoiceNotePlayer(this); player.addListener(new VoiceNotePlayerEventListener()); - mediaSessionConnector.setPlayer(player); - mediaSessionConnector.setEnabledPlaybackActions(SUPPORTED_ACTIONS); - mediaSessionConnector.setPlaybackPreparer(voiceNotePlaybackPreparer); - mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession)); + voiceNotePlayerCallback = new VoiceNotePlayerCallback(this, player); + mediaSession = new MediaSession.Builder(this, player).setCallback(voiceNotePlayerCallback).setId(SESSION_ID).build(); + keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getToken()); - VoiceNotePlaybackController voiceNotePlaybackController = new VoiceNotePlaybackController(player.getInternalPlayer(), voiceNotePlaybackParameters); - mediaSessionConnector.registerCustomCommandReceiver(voiceNotePlaybackController); - - setSessionToken(mediaSession.getSessionToken()); - - mediaSession.setActive(true); - keyClearedReceiver.register(); + setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this)); + setListener(new MediaSessionServiceListener()); } @Override public void onTaskRemoved(Intent rootIntent) { super.onTaskRemoved(rootIntent); - player.stop(); - player.clearMediaItems(); + mediaSession.getPlayer().stop(); + mediaSession.getPlayer().clearMediaItems(); } @Override public void onDestroy() { - super.onDestroy(); - mediaSession.setActive(false); - mediaSession.release(); - becomingNoisyReceiver.unregister(); - keyClearedReceiver.unregister(); player.release(); + mediaSession.release(); + mediaSession = null; + clearListener(); + mediaSession = null; + super.onDestroy(); + keyClearedReceiver.unregister(); } + @Nullable @Override - public @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) { - if (clientUid == Process.myUid()) { - return new BrowserRoot(EMPTY_ROOT_ID, null); - } else { - return null; - } - } - - @Override - public void onLoadChildren(@NonNull String parentId, @NonNull Result> result) { - result.sendResult(Collections.emptyList()); + public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) { + return mediaSession; } private class VoiceNotePlayerEventListener implements Player.Listener { @@ -150,20 +116,14 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { switch (playbackState) { case Player.STATE_BUFFERING: case Player.STATE_READY: - voiceNoteNotificationManager.showNotification(player); if (!playWhenReady) { stopForeground(false); - isForegroundService = false; - becomingNoisyReceiver.unregister(); } else { sendViewedReceiptForCurrentWindowIndex(); - becomingNoisyReceiver.register(); } break; default: - becomingNoisyReceiver.unregister(); - voiceNoteNotificationManager.hideNotification(); } } @@ -198,7 +158,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { currentWindowIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount(); if (isWithinThreshold && currentWindowIndex % 2 == 0) { - voiceNotePlaybackPreparer.loadMoreVoiceNotes(); + voiceNotePlayerCallback.loadMoreVoiceNotes(); } } @@ -217,13 +177,12 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { } Log.i(TAG, "onAudioAttributesChanged: Setting audio stream to " + stream); - mediaSession.setPlaybackToLocal(stream); } } private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) { if (isAudioMessage(currentWindowIndex)) { - return voiceNotePlaybackParameters.getParameters(); + return player.getPlaybackParameters(); } else { return null; } @@ -271,49 +230,48 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { } } - private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener { - - @Override - public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) { - if (ongoing && !isForegroundService) { - try { - ForegroundServiceUtil.start(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class)); - startForeground(notificationId, notification); - isForegroundService = true; - } catch (UnableToStartException e) { - Log.e(TAG, "Unable to start foreground service!", e); - } - } - } - - @Override - public void onNotificationCancelled(int notificationId, boolean dismissedByUser) { - stopForeground(true); - isForegroundService = false; - stopSelf(); - } - } - /** * Receiver to stop playback and kill the notification if user locks signal via screen lock. + * This registers itself as a receiver on the [Context] as soon as it can. */ private static class KeyClearedReceiver extends BroadcastReceiver { + private static final String TAG = Log.tag(KeyClearedReceiver.class); private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT); - private final Context context; - private final MediaControllerCompat controller; + private final Context context; + private final ListenableFuture controllerFuture; + private MediaController controller; private boolean registered; - private KeyClearedReceiver(@NonNull Context context, @NonNull MediaSessionCompat.Token token) { - this.context = context; - this.controller = new MediaControllerCompat(context, token); + private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) { + this.context = context; + Log.d(TAG, "Creating media controller…"); + controllerFuture = new MediaController.Builder(context, token).buildAsync(); + Futures.addCallback(controllerFuture, new FutureCallback<>() { + @Override public void onSuccess(@Nullable MediaController result) { + Log.e(TAG, "Successfully created media controller."); + controller = result; + register(); + } + + @Override public void onFailure(@NonNull Throwable t) { + } + }, ContextCompat.getMainExecutor(context)); } void register() { + if (controller == null) { + Log.e(TAG, "Failed to register KeyClearedReceiver because MediaController was null."); + } if (!registered) { - context.registerReceiver(this, KEY_CLEARED_FILTER); + if (Build.VERSION.SDK_INT >= 33) { + context.registerReceiver(this, KEY_CLEARED_FILTER, RECEIVER_NOT_EXPORTED); + } else { + context.registerReceiver(this, KEY_CLEARED_FILTER); + } registered = true; + Log.e(TAG, "Successfully registered."); } } @@ -322,48 +280,24 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { context.unregisterReceiver(this); registered = false; } + MediaController.releaseFuture(controllerFuture); } @Override public void onReceive(Context context, Intent intent) { - controller.getTransportControls().stop(); + if (controller == null) { + Log.e(TAG, "Received broadcast but could not stop playback because MediaController was null."); + } else { + Log.i(TAG, "Received broadcast, stopping playback."); + controller.stop(); + } } } - /** - * Receiver to pause playback when things become noisy. - */ - private static class BecomingNoisyReceiver extends BroadcastReceiver { - private static final IntentFilter NOISY_INTENT_FILTER = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY); - - private final Context context; - private final MediaControllerCompat controller; - - private boolean registered; - - private BecomingNoisyReceiver(Context context, MediaSessionCompat.Token token) { - this.context = context; - this.controller = new MediaControllerCompat(context, token); - } - - void register() { - if (!registered) { - context.registerReceiver(this, NOISY_INTENT_FILTER); - registered = true; - } - } - - void unregister() { - if (registered) { - context.unregisterReceiver(this); - registered = false; - } - } - - public void onReceive(Context context, @NonNull Intent intent) { - if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { - controller.getTransportControls().pause(); - } + private static class MediaSessionServiceListener implements Listener { + @Override + public void onForegroundServiceStartNotAllowedException() { + Log.e(TAG, "Could not start VoiceNotePlaybackService, encountered a ForegroundServiceStartNotAllowedException."); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayer.kt index c3a91f3abc..c2823f1745 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayer.kt @@ -1,29 +1,47 @@ package org.thoughtcrime.securesms.components.voice import android.content.Context -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.DefaultLoadControl -import com.google.android.exoplayer2.DefaultRenderersFactory -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.ForwardingPlayer -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.audio.AudioSink +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory +/** + * A lightweight wrapper around ExoPlayer that compartmentalizes some logic and adds a few functions, most importantly the seek behavior. + * + * @param context + */ +@OptIn(UnstableApi::class) class VoiceNotePlayer @JvmOverloads constructor( context: Context, - val internalPlayer: ExoPlayer = ExoPlayer.Builder(context) + private val internalPlayer: ExoPlayer = ExoPlayer.Builder(context) .setRenderersFactory(WorkaroundRenderersFactory(context)) .setMediaSourceFactory(SignalMediaSourceFactory(context)) .setLoadControl( DefaultLoadControl.Builder() .setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE) .build() - ).build().apply { - setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true) - } + ) + .setHandleAudioBecomingNoisy(true).build() ) : ForwardingPlayer(internalPlayer) { + init { + setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true) + } + + /** + * Required to expose this because this is unique to [ExoPlayer], not the generic [androidx.media3.common.Player] interface. + */ + fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) { + internalPlayer.setAudioAttributes(audioAttributes, handleAudioFocus) + } + override fun seekTo(windowIndex: Int, positionMs: Long) { super.seekTo(windowIndex, positionMs) @@ -46,6 +64,7 @@ class VoiceNotePlayer @JvmOverloads constructor( /** * @see RetryableInitAudioSink */ +@OptIn(androidx.media3.common.util.UnstableApi::class) class WorkaroundRenderersFactory(val context: Context) : DefaultRenderersFactory(context) { override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean): AudioSink? { return RetryableInitAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams, enableOffload) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayerCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayerCallback.kt new file mode 100644 index 0000000000..9bb2e5ca56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlayerCallback.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.voice + +import android.content.Context +import android.media.AudioManager +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.annotation.WorkerThread +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaItem.LocalConfiguration +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionCommands +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import org.signal.core.util.ThreadUtil +import org.signal.core.util.concurrent.SimpleTask +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.NoSuchMessageException +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.hasAudio +import java.util.Objects +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.stream.Collectors +import kotlin.math.max + +/** + * See [VoiceNotePlaybackService]. + */ +@OptIn(UnstableApi::class) +class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer) : MediaSession.Callback { + companion object { + private val SUPPORTED_ACTIONS = Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_PREPARE, + Player.COMMAND_STOP, + Player.COMMAND_SEEK_TO_DEFAULT_POSITION, + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_MEDIA_ITEM, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD, + Player.COMMAND_SET_SPEED_AND_PITCH, + Player.COMMAND_SET_REPEAT_MODE, + Player.COMMAND_GET_CURRENT_MEDIA_ITEM, + Player.COMMAND_GET_TIMELINE, + Player.COMMAND_GET_METADATA, + Player.COMMAND_SET_PLAYLIST_METADATA, + Player.COMMAND_SET_MEDIA_ITEM, + Player.COMMAND_CHANGE_MEDIA_ITEMS, + Player.COMMAND_GET_AUDIO_ATTRIBUTES, + Player.COMMAND_GET_TEXT, + Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS, + Player.COMMAND_RELEASE + ) + .build() + + private val CUSTOM_COMMANDS = SessionCommands.Builder() + .add(SessionCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, Bundle.EMPTY)) + .add(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)) + .build() + private const val DEFAULT_PLAYBACK_SPEED = 1f + private const val LIMIT: Long = 5 + } + + private val TAG = Log.tag(VoiceNotePlayerCallback::class.java) + private val EXECUTOR: Executor = Executors.newSingleThreadExecutor() + private val customLayout: List = mutableListOf().apply { + add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_PLAY_PAUSE).build()) + add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_STOP).build()) + } + private var canLoadMore = false + private var latestUri = Uri.EMPTY + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + session.setAvailableCommands(controller, CUSTOM_COMMANDS, SUPPORTED_ACTIONS) + return super.onConnect(session, controller) + } + + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + if (customLayout.isNotEmpty() && controller.controllerVersion != 0) { + session.setCustomLayout(controller, customLayout) + } + } + + override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList): ListenableFuture> { + mediaItems.forEach { + val uri = it.localConfiguration?.uri + if (uri != null) { + val extras = it.requestMetadata.extras + onPrepareFromUri(uri, extras) + } else { + throw UnsupportedOperationException("VoiceNotePlayerCallback does not support onPrepareFromMediaId/onPrepareFromSearch") + } + } + return super.onAddMediaItems(mediaSession, controller, mediaItems) + } + + private fun onPrepareFromUri(uri: Uri, extras: Bundle?) { + Log.d(TAG, "onPrepareFromUri: $uri") + if (extras == null) { + return + } + val messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID) + val threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID) + val progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0.0) + val singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false) + canLoadMore = false + latestUri = uri + SimpleTask.run( + EXECUTOR, + { + if (singlePlayback) { + if (messageId != -1L) { + return@run loadMediaItemsForSinglePlayback(messageId) + } else { + return@run loadMediaItemsForDraftPlayback(threadId, uri) + } + } else { + return@run loadMediaItemsForConsecutivePlayback(messageId) + } + } + ) { mediaItems: List -> + player.clearMediaItems() + if (mediaItems.isNotEmpty() && latestUri == uri) { + applyDescriptionsToQueue(mediaItems) + val window = max(0, indexOfPlayerMediaItemByUri(uri)) + player.addListener(object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + if (timeline.windowCount >= window) { + player.playWhenReady = false + player.playbackParameters = PlaybackParameters(DEFAULT_PLAYBACK_SPEED) + player.seekTo(window, (player.duration * progress).toLong()) + player.playWhenReady = true + player.removeListener(this) + } + } + }) + player.prepare() + canLoadMore = !singlePlayback + } else if (latestUri == uri) { + Log.w(TAG, "Requested playback but no voice notes could be found.") + ThreadUtil.postToMain { + Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT) + .show() + } + } + } + } + + override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture { + return when (customCommand.customAction) { + VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED -> incrementPlaybackSpeed(args) + VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM -> setAudioStream(args) + else -> super.onCustomCommand(session, controller, customCommand, args) + } + } + + private fun incrementPlaybackSpeed(extras: Bundle): ListenableFuture { + val speed = extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) + player.playbackParameters = PlaybackParameters(speed) + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + private fun setAudioStream(extras: Bundle): ListenableFuture { + val newStreamType: Int = extras.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) + + val currentStreamType = androidx.media3.common.util.Util.getStreamTypeForAudioUsage(player.audioAttributes.usage) + if (newStreamType != currentStreamType) { + val attributes = when (newStreamType) { + AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build() + AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build() + else -> throw AssertionError() + } + + player.playWhenReady = false + player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC) + if (newStreamType == AudioManager.STREAM_VOICE_CALL) { + player.playWhenReady = true + } + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + @MainThread + private fun applyDescriptionsToQueue(mediaItems: List) { + for (mediaItem in mediaItems) { + val playbackProperties = mediaItem.localConfiguration ?: continue + val holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri) + val next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem) + val currentIndex: Int = player.currentMediaItemIndex + if (holderIndex != -1) { + if (currentIndex != holderIndex) { + player.removeMediaItem(holderIndex) + player.addMediaItem(holderIndex, mediaItem) + } + if (currentIndex != holderIndex + 1) { + if (player.mediaItemCount > 1) { + player.removeMediaItem(holderIndex + 1) + } + player.addMediaItem(holderIndex + 1, next) + } + } else { + val insertLocation = indexAfter(mediaItem) + player.addMediaItem(insertLocation, next) + player.addMediaItem(insertLocation, mediaItem) + } + } + val itemsCount: Int = player.mediaItemCount + if (itemsCount > 0) { + val lastIndex = itemsCount - 1 + val last: MediaItem = player.getMediaItemAt(lastIndex) + if (last.localConfiguration?.uri == VoiceNoteMediaItemFactory.NEXT_URI) { + player.removeMediaItem(lastIndex) + if (player.mediaItemCount > 1) { + val end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last) + player.addMediaItem(lastIndex, end) + } + } + } + } + + private fun indexOfPlayerMediaItemByUri(uri: Uri): Int { + for (i in 0 until player.mediaItemCount) { + val playbackProperties: LocalConfiguration? = player.getMediaItemAt(i).playbackProperties + if (playbackProperties?.uri == uri) { + return i + } + } + return -1 + } + + private fun indexAfter(target: MediaItem): Int { + val size: Int = player.mediaItemCount + val targetMessageId = target.mediaMetadata.extras?.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID) ?: 0L + for (i in 0 until size) { + val mediaMetadata: MediaMetadata = player.getMediaItemAt(i).mediaMetadata + val messageId = mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID) + if (messageId > targetMessageId) { + return i + } + } + return size + } + + fun loadMoreVoiceNotes() { + if (!canLoadMore) { + return + } + val currentMediaItem: MediaItem = player.currentMediaItem ?: return + val messageId = currentMediaItem.mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID) + SimpleTask.run( + EXECUTOR, + { loadMediaItemsForConsecutivePlayback(messageId) } + ) { mediaItems: List -> + if (Util.hasItems(mediaItems) && canLoadMore) { + applyDescriptionsToQueue(mediaItems) + } + } + } + + private fun loadMediaItemsForSinglePlayback(messageId: Long): List { + return try { + val messageRecord = messages + .getMessageRecord(messageId) + if (!messageRecord.hasAudio()) { + Log.w(TAG, "Message does not contain audio.") + return emptyList() + } + val mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord) + mediaItem?.let { listOf(it) } ?: emptyList() + } catch (e: NoSuchMessageException) { + Log.w(TAG, "Could not find message.", e) + emptyList() + } + } + + private fun loadMediaItemsForDraftPlayback(threadId: Long, draftUri: Uri): List { + return listOf(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri)) + } + + @WorkerThread + private fun loadMediaItemsForConsecutivePlayback(messageId: Long): List { + return try { + val recordsAfter = messages.getMessagesAfterVoiceNoteInclusive(messageId, Companion.LIMIT) + recordsAfter.filter { it.hasAudio() }.stream() + .map { record: MessageRecord? -> + VoiceNoteMediaItemFactory + .buildMediaItem(context, record!!) + } + .filter { obj: MediaItem? -> Objects.nonNull(obj) } + .collect(Collectors.toList()) + } catch (e: NoSuchMessageException) { + Log.w(TAG, "Could not find message.", e) + emptyList() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityWakeLockManager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityWakeLockManager.kt index ee9b54e4a9..3beab4b3cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityWakeLockManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteProximityWakeLockManager.kt @@ -7,12 +7,13 @@ import android.hardware.SensorManager import android.media.AudioManager import android.os.Bundle import android.os.PowerManager -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.PlaybackStateCompat import androidx.fragment.app.FragmentActivity import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionCommand import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.util.ServiceUtil import java.util.concurrent.TimeUnit @@ -25,7 +26,7 @@ private const val PROXIMITY_THRESHOLD = 5f */ class VoiceNoteProximityWakeLockManager( private val activity: FragmentActivity, - private val mediaController: MediaControllerCompat + private val mediaController: MediaController ) : DefaultLifecycleObserver { private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG) @@ -33,7 +34,7 @@ class VoiceNoteProximityWakeLockManager( private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity) private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY) - private val mediaControllerCallback = MediaControllerCallback() + private val mediaControllerCallback = ProximityListener() private val hardwareSensorEventListener = HardwareSensorEventListener() private var startTime: Long = -1 @@ -46,7 +47,7 @@ class VoiceNoteProximityWakeLockManager( override fun onResume(owner: LifecycleOwner) { if (proximitySensor != null) { - mediaController.registerCallback(mediaControllerCallback) + mediaController.addListener(mediaControllerCallback) } } @@ -57,7 +58,7 @@ class VoiceNoteProximityWakeLockManager( } fun unregisterCallbacksAndRelease() { - mediaController.unregisterCallback(mediaControllerCallback) + mediaController.addListener(mediaControllerCallback) cleanUpWakeLock() } @@ -69,9 +70,6 @@ class VoiceNoteProximityWakeLockManager( private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) - private fun isPlayerActive() = mediaController.playbackState.state == PlaybackStateCompat.STATE_BUFFERING || - mediaController.playbackState.state == PlaybackStateCompat.STATE_PLAYING - private fun cleanUpWakeLock() { startTime = -1L sensorManager.unregisterListener(hardwareSensorEventListener) @@ -87,26 +85,29 @@ class VoiceNoteProximityWakeLockManager( private fun sendNewStreamTypeToPlayer(newStreamType: Int) { val params = Bundle() params.putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, newStreamType) - mediaController.sendCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, params, null) + mediaController.sendCustomCommand(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY), params) } - inner class MediaControllerCallback : MediaControllerCompat.Callback() { - override fun onPlaybackStateChanged(state: PlaybackStateCompat) { - if (!isActivityResumed()) { - return - } - - if (isPlayerActive()) { - if (startTime == -1L) { - Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.") - startTime = System.currentTimeMillis() - sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL) - } else { - Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration") + inner class ProximityListener : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + if (!isActivityResumed()) { + return + } + + if (player.isPlaying) { + if (startTime == -1L) { + Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.") + startTime = System.currentTimeMillis() + sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL) + } else { + Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration") + } + } else { + Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.") + cleanUpWakeLock() } - } else { - Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.") - cleanUpWakeLock() } } } @@ -116,7 +117,7 @@ class VoiceNoteProximityWakeLockManager( if (startTime == -1L || System.currentTimeMillis() - startTime <= 500 || !isActivityResumed() || - !isPlayerActive() || + !mediaController.isPlaying || event.sensor.type != Sensor.TYPE_PROXIMITY ) { return diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java deleted file mode 100644 index 20b254ce9a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.thoughtcrime.securesms.components.voice; - -import android.support.v4.media.MediaDescriptionCompat; -import android.support.v4.media.session.MediaSessionCompat; - -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator; - -/** - * Navigator to help support seek forward and back. - */ -final class VoiceNoteQueueNavigator extends TimelineQueueNavigator { - private static final MediaDescriptionCompat EMPTY = new MediaDescriptionCompat.Builder().build(); - - public VoiceNoteQueueNavigator(@NonNull MediaSessionCompat mediaSession) { - super(mediaSession); - } - - @Override - public @NonNull MediaDescriptionCompat getMediaDescription(@NonNull Player player, int windowIndex) { - MediaItem mediaItem = windowIndex >= 0 && windowIndex < player.getMediaItemCount() ? player.getMediaItemAt(windowIndex) : null; - - if (mediaItem == null || mediaItem.playbackProperties == null) { - return EMPTY; - } - - MediaDescriptionCompat mediaDescriptionCompat = (MediaDescriptionCompat) mediaItem.playbackProperties.tag; - if (mediaDescriptionCompat == null) { - return EMPTY; - } - - return mediaDescriptionCompat; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 83821a5a92..bcdb0dfd0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -37,7 +37,7 @@ import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.exoplayer2.MediaItem; +import androidx.media3.common.MediaItem; import org.signal.core.util.logging.Log; import org.signal.paging.PagingController; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 48d4f3c6c1..f4a529eb81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -57,9 +57,9 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.lifecycle.LifecycleOwner; +import androidx.media3.common.MediaItem; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.exoplayer2.MediaItem; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.collect.Sets; @@ -261,7 +261,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private boolean hasWallpaper; private float lastYDownRelativeToThis; private ProjectionList colorizerProjections = new ProjectionList(3); - private boolean isBound = false; + private boolean isBound = false; private final Runnable shrinkBubble = new Runnable() { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index 7a31c3c9eb..4e7908b523 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -11,8 +11,8 @@ import android.view.ViewGroup import androidx.core.text.HtmlCompat import androidx.core.view.children import androidx.lifecycle.LifecycleOwner +import androidx.media3.common.MediaItem import androidx.recyclerview.widget.RecyclerView -import com.google.android.exoplayer2.MediaItem import org.signal.core.util.logging.Log import org.signal.core.util.toOptional import org.thoughtcrime.securesms.BindableConversationItem diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index 5d2ef2670c..a16f335017 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.EarlyMessageCache; import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.IasKeyStore; +import org.thoughtcrime.securesms.video.exo.ExoPlayerPool; import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat; @@ -607,7 +608,7 @@ public class ApplicationDependencies { return giphyMp4Cache; } - public static @NonNull SimpleExoPlayerPool getExoPlayerPool() { + public static @NonNull ExoPlayerPool getExoPlayerPool() { if (exoPlayerPool == null) { synchronized (LOCK) { if (exoPlayerPool == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java index fa3b5367ce..7c5df8adf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4Playable.java @@ -5,7 +5,7 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.MediaItem; +import androidx.media3.common.MediaItem; import org.thoughtcrime.securesms.util.Projection; diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java index eeb8c75e8d..34cccb9bb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ProjectionPlayerHolder.java @@ -9,14 +9,17 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.AspectRatioFrameLayout; + import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -30,6 +33,7 @@ import java.util.List; /** * Object which holds on to an injected video player. */ +@OptIn(markerClass = UnstableApi.class) public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, DefaultLifecycleObserver { private static final String TAG = Log.tag(GiphyMp4ProjectionPlayerHolder.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java index f66d3dcbcf..4214779077 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4VideoPlayer.java @@ -10,13 +10,15 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; import androidx.lifecycle.DefaultLifecycleObserver; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.StyledPlayerView; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.AspectRatioFrameLayout; +import androidx.media3.ui.PlayerView; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -26,14 +28,15 @@ import org.thoughtcrime.securesms.util.Projection; /** * Video Player class specifically created for the GiphyMp4Fragment. */ +@OptIn(markerClass = UnstableApi.class) public final class GiphyMp4VideoPlayer extends FrameLayout implements DefaultLifecycleObserver { @SuppressWarnings("unused") private static final String TAG = Log.tag(GiphyMp4VideoPlayer.class); - private final StyledPlayerView exoView; - private ExoPlayer exoPlayer; - private CornerMask cornerMask; + private final PlayerView exoView; + private ExoPlayer exoPlayer; + private CornerMask cornerMask; public GiphyMp4VideoPlayer(Context context) { this(context, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java index 0478e27d2f..ae471b62fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/mp4/GiphyMp4ViewHolder.java @@ -12,8 +12,11 @@ import androidx.annotation.Nullable; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; + +import androidx.annotation.OptIn; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.ui.AspectRatioFrameLayout; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette; @@ -28,14 +31,15 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder; /** * Holds a view which will either play back an MP4 gif or show its still. */ +@OptIn(markerClass = UnstableApi.class) final class GiphyMp4ViewHolder extends MappingViewHolder implements GiphyMp4Playable { private static final Projection.Corners CORNERS = new Projection.Corners(ViewUtil.dpToPx(8)); - private final AspectRatioFrameLayout container; - private final ImageView stillImage; - private final GiphyMp4Adapter.Callback listener; - private final Drawable placeholder; + private final AspectRatioFrameLayout container; + private final ImageView stillImage; + private final GiphyMp4Adapter.Callback listener; + private final Drawable placeholder; private float aspectRatio; private MediaItem mediaItem; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index 5a0cf473b2..05e7c43a19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -8,7 +8,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; -import com.google.android.exoplayer2.util.MimeTypes; +import androidx.media3.common.MimeTypes; import org.greenrobot.eventbus.EventBus; import org.signal.core.util.logging.Log; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java index f663d81f0c..92c1c6ee4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java @@ -7,7 +7,6 @@ import android.content.Intent; import android.content.res.Configuration; import android.net.Uri; import android.os.Bundle; -import android.util.Size; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -111,7 +110,7 @@ public final class MediaOverviewPageFragment extends Fragment public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity()); + voiceNoteMediaController = new VoiceNoteMediaController(requireActivity(), false); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewPlayerControlView.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewPlayerControlView.kt index 0a1b638014..666dd2d160 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewPlayerControlView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewPlayerControlView.kt @@ -10,13 +10,15 @@ import android.view.animation.PathInterpolator import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView +import androidx.annotation.OptIn import androidx.appcompat.widget.AppCompatImageButton import androidx.core.content.ContextCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView import androidx.recyclerview.widget.RecyclerView import com.airbnb.lottie.LottieAnimationView import com.airbnb.lottie.LottieProperty import com.airbnb.lottie.model.KeyPath -import com.google.android.exoplayer2.ui.PlayerControlView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.visible @@ -27,6 +29,7 @@ import kotlin.time.toDuration * The bottom bar for the media preview. This includes the standard seek bar as well as playback controls, * but adds forward and share buttons as well as a recyclerview that can be populated with a rail of thumbnails. */ +@OptIn(UnstableApi::class) class MediaPreviewPlayerControlView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Activity.kt index 8c31200a98..89b9aab28d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Activity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Activity.kt @@ -122,7 +122,7 @@ class MediaPreviewV2Activity : PassphraseRequiredActivity(), VoiceNoteMediaContr viewModel.setIsInSharedAnimation(false) } - voiceNoteMediaController = VoiceNoteMediaController(this) + voiceNoteMediaController = VoiceNoteMediaController(this, false) val systemBarColor = ContextCompat.getColor(this, R.color.signal_dark_colorSurface) window.statusBarColor = systemBarColor diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java index 48cf9941b9..f22f85bd6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoMediaPreviewFragment.java @@ -9,20 +9,22 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; import androidx.lifecycle.ViewModelProvider; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.ui.PlayerControlView; -import com.google.android.exoplayer2.ui.PlayerControlView; - +import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.mms.VideoSlide; -import org.signal.core.util.concurrent.LifecycleDisposable; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.video.VideoPlayer; import java.util.concurrent.TimeUnit; +@OptIn(markerClass = UnstableApi.class) public final class VideoMediaPreviewFragment extends MediaPreviewFragment { private static final String TAG = Log.tag(VideoMediaPreviewFragment.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java index 6f1b37b25f..a7990d09ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -18,7 +18,7 @@ import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.exoplayer2.MediaItem; +import androidx.media3.common.MediaItem; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.R; diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt index 864652d401..cd6559cd68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt @@ -4,10 +4,10 @@ import android.content.Context import android.net.Uri import androidx.annotation.WorkerThread import androidx.fragment.app.FragmentManager -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.PlaybackException -import com.google.android.exoplayer2.Player +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.ThreadUtil diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt index d20b860300..a8c0b02788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt @@ -81,7 +81,7 @@ class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControll super.onCreate(savedInstanceState, ready) setContentView(R.layout.fragment_container) - voiceNoteMediaController = VoiceNoteMediaController(this) + voiceNoteMediaController = VoiceNoteMediaController(this, false) if (savedInstanceState == null) { replaceStoryViewerFragment() diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java index 1f952b4ded..27cac1570b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java @@ -8,7 +8,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; -import com.google.android.exoplayer2.util.MimeTypes; +import androidx.media3.common.MimeTypes; import com.google.common.io.ByteStreams; import org.signal.core.util.logging.Log; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index 161f88b9cb..564e763b0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -26,18 +26,20 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.Tracks; -import com.google.android.exoplayer2.source.ClippingMediaSource; -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.PlayerControlView; -import com.google.android.exoplayer2.ui.StyledPlayerView; +import androidx.annotation.OptIn; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.common.Tracks; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.source.ClippingMediaSource; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.ui.AspectRatioFrameLayout; +import androidx.media3.ui.PlayerControlView; +import androidx.media3.ui.PlayerView; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -48,12 +50,13 @@ import org.thoughtcrime.securesms.mms.VideoSlide; import java.util.Objects; import java.util.concurrent.TimeUnit; +@OptIn(markerClass = UnstableApi.class) public class VideoPlayer extends FrameLayout { @SuppressWarnings("unused") private static final String TAG = Log.tag(VideoPlayer.class); - private final StyledPlayerView exoView; + private final PlayerView exoView; private final DefaultMediaSourceFactory mediaSourceFactory; private ExoPlayer exoPlayer; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java index dd58cb904b..13e7ecd555 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java @@ -6,10 +6,13 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; import org.thoughtcrime.securesms.providers.BlobProvider; @@ -20,6 +23,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +@OptIn(markerClass = UnstableApi.class) class BlobDataSource implements DataSource { private final @NonNull Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java index 8b901dcbea..726f142975 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java @@ -6,10 +6,12 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.annotation.OptIn; +import androidx.media3.common.C; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.net.ChunkedDataFetcher; @@ -25,6 +27,7 @@ import okhttp3.OkHttpClient; /** * DataSource which utilizes ChunkedDataFetcher to download video content via Signal content proxy. */ +@OptIn(markerClass = UnstableApi.class) class ChunkedDataSource implements DataSource { private final OkHttpClient okHttpClient; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt index 1901c0844a..5877676fb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ExoPlayer.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.video.exo -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.Player +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer fun ExoPlayer.configureForGifPlayback() { repeatMode = Player.REPEAT_MODE_ALL diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/PartDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/PartDataSource.java index 081c7623f0..f952b8c19d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/PartDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/PartDataSource.java @@ -7,9 +7,11 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.database.AttachmentTable; @@ -23,6 +25,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +@OptIn(markerClass = UnstableApi.class) class PartDataSource implements DataSource { private final @NonNull Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalDataSource.java index 4f8180ec71..58098f467f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalDataSource.java @@ -5,12 +5,16 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultDataSourceFactory; +import androidx.media3.datasource.TransferListener; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.providers.BlobProvider; @@ -22,11 +26,12 @@ import java.util.Map; import okhttp3.OkHttpClient; -/** + /** * Go-to {@link DataSource} that handles all of our various types of video sources. * Will defer to other {@link DataSource}s depending on the URI. */ -public class SignalDataSource implements DataSource { + @OptIn(markerClass = UnstableApi.class) + public class SignalDataSource implements DataSource { private final DefaultDataSource defaultDataSource; private final PartDataSource partDataSource; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java index 9e59a562a6..222b354929 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SignalMediaSourceFactory.java @@ -1,33 +1,26 @@ package org.thoughtcrime.securesms.video.exo; import android.content.Context; -import android.net.Uri; -import android.support.v4.media.MediaDescriptionCompat; import androidx.annotation.NonNull; import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.DrmSessionManagerProvider; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; -import com.google.android.exoplayer2.extractor.ExtractorsFactory; -import com.google.android.exoplayer2.offline.StreamKey; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MediaSourceFactory; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; - -import java.util.List; +import androidx.annotation.OptIn; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.DataSource; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.ProgressiveMediaSource; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.extractor.DefaultExtractorsFactory; +import androidx.media3.extractor.ExtractorsFactory; /** * This class is responsible for creating a MediaSource object for a given Uri, using {@link SignalDataSource.Factory}. */ -@SuppressWarnings("deprecation") -public final class SignalMediaSourceFactory implements MediaSourceFactory { +@OptIn(markerClass = UnstableApi.class) +public final class SignalMediaSourceFactory implements MediaSource.Factory { private final ProgressiveMediaSource.Factory progressiveMediaSourceFactory; @@ -38,32 +31,19 @@ public final class SignalMediaSourceFactory implements MediaSourceFactory { progressiveMediaSourceFactory = new ProgressiveMediaSource.Factory(attachmentDataSourceFactory, extractorsFactory); } - /** - * Creates a MediaSource for a given MediaDescriptionCompat - * - * @param description The description to build from - * - * @return A preparable MediaSource - */ - public @NonNull MediaSource createMediaSource(MediaDescriptionCompat description) { - return progressiveMediaSourceFactory.createMediaSource( - new MediaItem.Builder().setUri(description.getMediaUri()).setTag(description).build() - ); - } - @Override - public MediaSourceFactory setDrmSessionManagerProvider(@Nullable DrmSessionManagerProvider drmSessionManagerProvider) { + public MediaSource.Factory setDrmSessionManagerProvider(@Nullable DrmSessionManagerProvider drmSessionManagerProvider) { return progressiveMediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider); } @Override - public MediaSourceFactory setLoadErrorHandlingPolicy(@Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + public MediaSource.Factory setLoadErrorHandlingPolicy(@Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { return progressiveMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); } @Override public int[] getSupportedTypes() { - return new int[] { C.TYPE_OTHER }; + return new int[] { C.CONTENT_TYPE_OTHER }; } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt index eb99ac8949..a9e93e6338 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/SimpleExoPlayerPool.kt @@ -2,13 +2,14 @@ package org.thoughtcrime.securesms.video.exo import android.content.Context import androidx.annotation.MainThread -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.mediacodec.MediaCodecUtil -import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.upstream.DataSource -import com.google.android.exoplayer2.util.MimeTypes +import androidx.annotation.OptIn +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.mediacodec.MediaCodecUtil +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.net.ContentProxySelector @@ -17,8 +18,9 @@ import org.thoughtcrime.securesms.util.DeviceProperties import kotlin.time.Duration.Companion.seconds /** - * ExoPlayerPool concrete instance which helps to manage a pool of SimpleExoPlayer objects + * ExoPlayerPool concrete instance which helps to manage a pool of ExoPlayer objects */ +@OptIn(markerClass = [UnstableApi::class]) class SimpleExoPlayerPool(context: Context) : ExoPlayerPool(MAXIMUM_RESERVED_PLAYERS) { private val context: Context = context.applicationContext private val okHttpClient = ApplicationDependencies.getOkHttpClient().newBuilder().proxySelector(ContentProxySelector()).build() @@ -41,7 +43,7 @@ class SimpleExoPlayerPool(context: Context) : ExoPlayerPool(MAXIMUM_R } else { 0 } - } catch (ignored: DecoderQueryException) { + } catch (ignored: MediaCodecUtil.DecoderQueryException) { 0 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java index c1ae7d93f1..906c49f05a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperViewHolder.java @@ -7,9 +7,11 @@ import android.widget.ImageView; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.ui.AspectRatioFrameLayout; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.DisplayMetricsUtil; @@ -17,6 +19,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.Factory; import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory; import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder; +@OptIn(markerClass = UnstableApi.class) class ChatWallpaperViewHolder extends MappingViewHolder { private final AspectRatioFrameLayout frame; diff --git a/app/src/main/res/layout/chat_wallpaper_preview_fragment_adapter_item.xml b/app/src/main/res/layout/chat_wallpaper_preview_fragment_adapter_item.xml index e3937e485e..6b538bdc0b 100644 --- a/app/src/main/res/layout/chat_wallpaper_preview_fragment_adapter_item.xml +++ b/app/src/main/res/layout/chat_wallpaper_preview_fragment_adapter_item.xml @@ -1,6 +1,6 @@ - - + diff --git a/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml b/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml index fbdd2bacdb..b3442bb632 100644 --- a/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml +++ b/app/src/main/res/layout/chat_wallpaper_selection_fragment_adapter_item.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml index 1ac53c9f81..19328735d1 100644 --- a/app/src/main/res/layout/exo_player_control_view.xml +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -30,7 +30,7 @@ android:paddingRight="4dp" android:textColor="@color/signal_colorOnSurface" /> - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/media_preview_exoplayer_layout.xml b/app/src/main/res/layout/media_preview_exoplayer_layout.xml index 78fede4eda..bf9f83f25c 100644 --- a/app/src/main/res/layout/media_preview_exoplayer_layout.xml +++ b/app/src/main/res/layout/media_preview_exoplayer_layout.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - + + + + + + + + @@ -1045,6 +1053,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -1061,6 +1074,78 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2259,78 +2344,6 @@ https://docs.gradle.org/current/userguide/dependency_verification.html - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -