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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-