Upgrade to AndroidX Media3.

This commit is contained in:
Nicholas 2023-08-15 14:01:15 -04:00 committed by Cody Henthorne
parent 4cbcee85d6
commit 11cfe5ee82
55 changed files with 1508 additions and 1594 deletions

View file

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

View file

@ -1134,9 +1134,11 @@
<service
android:name=".components.voice.VoiceNotePlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>

View file

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

View file

@ -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.
* <p>
* This class assumes that it will be created within the scope of Activity#onCreate
* <p>
* 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> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
private LiveData<Optional<VoiceNotePlayerView.State>> 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<String> 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<VoiceNotePlaybackState> getVoiceNotePlaybackState() {
return voiceNotePlaybackState;
}
public LiveData<Optional<VoiceNotePlayerView.State>> 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> voiceNotePlaybackState;
private ProgressEventHandler(@NonNull MediaControllerCompat mediaController,
@NonNull MutableLiveData<VoiceNotePlaybackState> 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);
}
}
}
}
}

View file

@ -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<Optional<VoiceNotePlayerView.State>>
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<String, Optional<VoiceNotePlayerView.State>> { displayName: String ->
Optional.of<VoiceNotePlayerView.State>(
VoiceNotePlayerView.State(
uri,
messageId,
threadId,
!isPlaying,
senderId,
threadRecipientId,
messagePosition,
timestamp,
displayName,
playheadPositionMillis,
trackDuration,
speed
)
)
}
} else {
return@switchMap DefaultValueLiveData<Optional<VoiceNotePlayerView.State>>(Optional.empty<VoiceNotePlayerView.State>())
}
}
}
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<VoiceNotePlaybackState>
) : 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)
}

View file

@ -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();
}
}

View file

@ -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<CommandButton>, 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<CommandButton>,
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<CommandButton>,
showPauseButton: Boolean
): ImmutableList<CommandButton> {
val commandButtons = ImmutableList.Builder<CommandButton>()
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"
}
}

View file

@ -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;
}
}
}

View file

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

View file

@ -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);
}
}
}

View file

@ -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<MediaItem> 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<MediaItem> 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<MediaItem> loadMediaItemsForDraftPlayback(long threadId, @NonNull Uri draftUri) {
return Collections
.singletonList(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri));
}
@WorkerThread
private @NonNull List<MediaItem> loadMediaItemsForConsecutivePlayback(long messageId) {
try {
List<MessageRecord> 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<MessageRecord> buildFilteredMessageRecordList(@NonNull List<MessageRecord> 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;
}
}

View file

@ -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<List<MediaBrowserCompat.MediaItem>> 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<MediaController> 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.");
}
}
}

View file

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

View file

@ -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<CommandButton> = mutableListOf<CommandButton>().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<MediaItem>): ListenableFuture<MutableList<MediaItem>> {
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<MediaItem> ->
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<SessionResult> {
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<SessionResult> {
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<SessionResult> {
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<MediaItem>) {
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<MediaItem> ->
if (Util.hasItems(mediaItems) && canLoadMore) {
applyDescriptionsToQueue(mediaItems)
}
}
}
private fun loadMediaItemsForSinglePlayback(messageId: Long): List<MediaItem> {
return try {
val messageRecord = messages
.getMessageRecord(messageId)
if (!messageRecord.hasAudio()) {
Log.w(TAG, "Message does not contain audio.")
return emptyList<MediaItem>()
}
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<MediaItem> {
return listOf<MediaItem>(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri))
}
@WorkerThread
private fun loadMediaItemsForConsecutivePlayback(messageId: Long): List<MediaItem> {
return try {
val recordsAfter = messages.getMessagesAfterVoiceNoteInclusive(messageId, Companion.LIMIT)
recordsAfter.filter { it.hasAudio() }.stream()
.map<MediaItem?> { 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()
}
}
}

View file

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

View file

@ -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;
}
}

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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);

View file

@ -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);

View file

@ -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<GiphyImage> 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;

View file

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

View file

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

View file

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

View file

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

View file

@ -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);

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ExoPlayer>(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<ExoPlayer>(MAXIMUM_R
} else {
0
}
} catch (ignored: DecoderQueryException) {
} catch (ignored: MediaCodecUtil.DecoderQueryException) {
0
}

View file

@ -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<ChatWallpaperSelectionMappingModel> {
private final AspectRatioFrameLayout frame;

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.media3.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
@ -21,4 +21,4 @@
android:visibility="gone"
tools:alpha="0.2f"
tools:visibility="visible" />
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
</androidx.media3.ui.AspectRatioFrameLayout>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.media3.ui.AspectRatioFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
@ -19,4 +19,4 @@
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.WallpaperPreview"
tools:src="@drawable/test_gradient" />
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
</androidx.media3.ui.AspectRatioFrameLayout>

View file

@ -30,7 +30,7 @@
android:paddingRight="4dp"
android:textColor="@color/signal_colorOnSurface" />
<com.google.android.exoplayer2.ui.DefaultTimeBar
<androidx.media3.ui.DefaultTimeBar
android:id="@id/exo_progress"
android:layout_width="0dp"
android:layout_height="55dp"

View file

@ -7,7 +7,7 @@
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.exoplayer2.ui.StyledPlayerView
<androidx.media3.ui.PlayerView
android:id="@+id/video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -6,7 +6,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout
<androidx.media3.ui.AspectRatioFrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="0dp"
@ -19,5 +19,5 @@
android:scaleType="fitXY"
app:shapeAppearanceOverlay="@style/Signal.ShapeOverlay.Rounded" />
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
</androidx.media3.ui.AspectRatioFrameLayout>
</FrameLayout>

View file

@ -6,7 +6,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout
<androidx.media3.ui.AspectRatioFrameLayout
android:id="@+id/exo_content_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -5,7 +5,7 @@
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<com.google.android.exoplayer2.ui.AspectRatioFrameLayout
<androidx.media3.ui.AspectRatioFrameLayout
android:id="@+id/exo_content_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -7,7 +7,7 @@
android:orientation="vertical"
tools:viewBindingIgnore="true">
<com.google.android.exoplayer2.ui.StyledPlayerView
<androidx.media3.ui.PlayerView
android:id="@+id/video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -8,7 +8,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.exoplayer2.ui.StyledPlayerView
<androidx.media3.ui.PlayerView
android:id="@+id/video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"

View file

@ -1,46 +1,49 @@
package org.thoughtcrime.securesms.components.voice
import android.app.Application
import android.content.Context
import android.media.AudioManager
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.audio.AudioAttributes
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.test.core.app.ApplicationProvider
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.Mockito.any
import org.mockito.Mockito.anyBoolean
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class VoiceNotePlaybackControllerTest {
class VoiceNotePlayerCallbackTest {
private val mediaSessionCompat = mock(MediaSessionCompat::class.java)
private val playbackParameters = VoiceNotePlaybackParameters(mediaSessionCompat)
private val context: Context = ApplicationProvider.getApplicationContext()
private val mediaAudioAttributes = AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
private val callAudioAttributes = AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
private val player: SimpleExoPlayer = mock(SimpleExoPlayer::class.java)
private val testSubject = VoiceNotePlaybackController(player, playbackParameters)
private val session = mock(MediaSession::class.java)
private val controllerInfo = mock(MediaSession.ControllerInfo::class.java)
private val player: VoiceNotePlayer = mock(VoiceNotePlayer::class.java)
private val testSubject = VoiceNotePlayerCallback(context, player)
@Test
fun `Given stream is media, When I onCommand for voice, then I expect the stream to switch to voice and continue playback`() {
// GIVEN
`when`(player.audioAttributes).thenReturn(mediaAudioAttributes)
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_VOICE_CALL) }
val expected = callAudioAttributes
// WHEN
testSubject.onCommand(player, command, extras, null)
testSubject.onCustomCommand(session, controllerInfo, command, extras)
// THEN
verify(player).playWhenReady = false
@ -53,12 +56,12 @@ class VoiceNotePlaybackControllerTest {
// GIVEN
`when`(player.audioAttributes).thenReturn(callAudioAttributes)
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) }
val expected = mediaAudioAttributes
// WHEN
testSubject.onCommand(player, command, extras, null)
testSubject.onCustomCommand(session, controllerInfo, command, extras)
// THEN
verify(player).playWhenReady = false
@ -71,11 +74,11 @@ class VoiceNotePlaybackControllerTest {
// GIVEN
`when`(player.audioAttributes).thenReturn(callAudioAttributes)
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_VOICE_CALL) }
// WHEN
testSubject.onCommand(player, command, extras, null)
testSubject.onCustomCommand(session, controllerInfo, command, extras)
// THEN
verify(player, Mockito.never()).playWhenReady = anyBoolean()
@ -87,11 +90,11 @@ class VoiceNotePlaybackControllerTest {
// GIVEN
`when`(player.audioAttributes).thenReturn(mediaAudioAttributes)
val command = VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM
val command = SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY)
val extras = Bundle().apply { putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) }
// WHEN
testSubject.onCommand(player, command, extras, null)
testSubject.onCustomCommand(session, controllerInfo, command, extras)
// THEN
verify(player, Mockito.never()).playWhenReady = anyBoolean()

View file

@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.video.exo
import com.google.android.exoplayer2.ExoPlayer
import androidx.media3.exoplayer.ExoPlayer
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail

View file

@ -9,6 +9,7 @@ dependencyResolutionManagement {
version('androidx-camera', '1.2.3')
version('androidx-fragment', '1.6.1')
version('androidx-lifecycle', '2.6.1')
version('androidx-media3', '1.1.0')
version('androidx-navigation', '2.6.0')
version('androidx-window', '1.0.0')
version('exoplayer', '2.19.0')
@ -60,6 +61,9 @@ dependencyResolutionManagement {
library('androidx-preference', 'androidx.preference:preference:1.0.0')
library('androidx-gridlayout', 'androidx.gridlayout:gridlayout:1.0.0')
library('androidx-exifinterface', 'androidx.exifinterface:exifinterface:1.3.3')
library('androidx-media3-exoplayer', 'androidx.media3', 'media3-exoplayer').versionRef('androidx-media3')
library('androidx-media3-session', 'androidx.media3', 'media3-session').versionRef('androidx-media3')
library('androidx-media3-ui', 'androidx.media3', 'media3-ui').versionRef('androidx-media3')
library('androidx-multidex', 'androidx.multidex:multidex:2.0.1')
library('androidx-navigation-fragment-ktx', 'androidx.navigation', 'navigation-fragment-ktx').versionRef('androidx-navigation')
library('androidx-navigation-ui-ktx', 'androidx.navigation', 'navigation-ui-ktx').versionRef('androidx-navigation')
@ -100,11 +104,7 @@ dependencyResolutionManagement {
library('google-guava-android', 'com.google.guava:guava:30.0-android')
library('google-flexbox', 'com.google.android.flexbox:flexbox:3.0.0')
// Exoplayer
library('exoplayer-core', 'com.google.android.exoplayer', 'exoplayer-core').versionRef('exoplayer')
library('exoplayer-ui', 'com.google.android.exoplayer', 'exoplayer-ui').versionRef('exoplayer')
library('exoplayer-extension-mediasession', 'com.google.android.exoplayer', 'extension-mediasession').versionRef('exoplayer')
bundle('exoplayer', ['exoplayer-core', 'exoplayer-ui', 'exoplayer-extension-mediasession'])
bundle('media3', ['androidx-media3-exoplayer', 'androidx-media3-session', 'androidx-media3-ui'])
// Firebase
library('firebase-messaging', 'com.google.firebase:firebase-messaging:23.1.2')

View file

@ -328,6 +328,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="632a0e5407461de774409352940e292a291037724207a787820c77daf7d33b72" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.collection" name="collection" version="1.2.0">
<artifact name="collection-1.2.0.jar">
<sha256 value="16d77e8c443fa55fe9a6074d00445d520ca5c9f913cefdbf4828356255214e42" origin="Generated by Gradle"/>
</artifact>
<artifact name="collection-1.2.0.module">
<sha256 value="6c4c0f9e7dab6d983858e67dd99a93af9d9f7d02ac1f8f648ce1fecc84afffa2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.collection" name="collection-ktx" version="1.1.0">
<artifact name="collection-ktx-1.1.0.jar">
<sha256 value="2bfc54475c047131913361f56d0f7f019c6e5bee53eeb0eb7d94a7c499a05227" origin="Generated by Gradle"/>
@ -1045,6 +1053,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="e71c328ceef5c4a7d76f2d86df1b65d65fe2acf868b1a4efd84a3f34336186d8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media" name="media" version="1.0.0">
<artifact name="media-1.0.0.aar">
<sha256 value="b23b527b2bac870c4a7451e6982d7132e413e88d7f27dbeb1fc7640a720cd9ee" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media" name="media" version="1.4.3">
<artifact name="media-1.4.3.aar">
<sha256 value="89a83d648dafeb5160a76dcdf996735d746f410c115166cf15693b5208d73d95" origin="Generated by Gradle"/>
@ -1061,6 +1074,78 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="44d12bf8f7ce879f03af00ba01b556751d0073440fed1150128e903974c4def0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-common" version="1.1.0">
<artifact name="media3-common-1.1.0.aar">
<sha256 value="1dd31798aa20f040528d3c900dc262122a3f4657ac96f32dcbb4c8560ca8310d" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-common-1.1.0.module">
<sha256 value="33aab3e709760bde1102c80768d934f4cb3f70cdb6ff8d980f70b0d5d9f24e1f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-container" version="1.1.0">
<artifact name="media3-container-1.1.0.aar">
<sha256 value="22c06a510447df925ca51a974ea3fdb278f927392881189346f720be9c61fa1c" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-container-1.1.0.module">
<sha256 value="b739cf49445bf078460c84ad2d1da0b84d4235624a41e59f1fa33b43a54cfe3f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-database" version="1.1.0">
<artifact name="media3-database-1.1.0.aar">
<sha256 value="5fe92576a8bfa75af43c7f0f44b51c16d65dbb682c2f20c89e789ef7b695fdd7" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-database-1.1.0.module">
<sha256 value="e4ec2441bf55622466fbeef97d1bf09264312acb09576dd8d7a8d5331a2740f1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-datasource" version="1.1.0">
<artifact name="media3-datasource-1.1.0.aar">
<sha256 value="726447a737073feaffe32a71607aff10d0e078593e6040ea9c0eac8656bd3814" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-datasource-1.1.0.module">
<sha256 value="b53c6041466b97dc75d6c9604113bffccc43444470e47130ce510f4804e00ef0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-decoder" version="1.1.0">
<artifact name="media3-decoder-1.1.0.aar">
<sha256 value="fdecf3e7ef2dec69dddaa6958506158b1ccbae3c83542b46448d595727510f5f" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-decoder-1.1.0.module">
<sha256 value="8560ac3db7a314eb46ce026dd254ebdc7373f5e82b8a40f5b7930aa16c6da5f9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-exoplayer" version="1.1.0">
<artifact name="media3-exoplayer-1.1.0.aar">
<sha256 value="ec7b813907df1c756d39c39be392097289a1e6915f0e8c986fc6772d2dfd38a2" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-exoplayer-1.1.0.module">
<sha256 value="95f918a5a224f19e69c81bfe9917693875da28f555f84b03459347373802475a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-extractor" version="1.1.0">
<artifact name="media3-extractor-1.1.0.aar">
<sha256 value="5d6a3e089701450fa489a604f92580011d21c54fe7eeb3fee067efc541e4e58a" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-extractor-1.1.0.module">
<sha256 value="5c7156143dcdce93c4b35a6cf9bd2bd34a9b4d003cb6ec2e0cebe5e9623b5ba5" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-session" version="1.1.0">
<artifact name="media3-session-1.1.0.aar">
<sha256 value="abae9db48fa3907b833aaddcc6c545c4f86bfa6a03aae3ad9de75a049daed604" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-session-1.1.0.module">
<sha256 value="f210e40037916a1e8d2b4b63c2467573db2323a97ff45007e5c7a3b5d73c4bd3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.media3" name="media3-ui" version="1.1.0">
<artifact name="media3-ui-1.1.0.aar">
<sha256 value="231359bf75f653cb3574b21bbde29ac511cf2516a3b14044bdfb25a36ad502c7" origin="Generated by Gradle"/>
</artifact>
<artifact name="media3-ui-1.1.0.module">
<sha256 value="9ee4eabef9c5658681e79dc2bb2b6bc931111e25c33b53be1b958e5d4a309392" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.multidex" name="multidex" version="2.0.1">
<artifact name="multidex-2.0.1.aar">
<sha256 value="42dd32ff9f97f85771b82a20003a8d70f68ab7b4ba328964312ce0732693db09" origin="Generated by Gradle"/>
@ -2259,78 +2344,6 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="cb9353ef1791ae17097d878ca711e25a9c32cec9042adc49b00cadfee1a7290b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-common" version="2.19.0">
<artifact name="exoplayer-common-2.19.0.aar">
<sha256 value="275b1e632ea0c84c72d1dbf7d269dac56b16b91143ada275e839f43b3c0957c3" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-common-2.19.0.module">
<sha256 value="4bc5417380075718238a6d2a9ef793eb924947d82c237e6fe92ff1625440aa8d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-container" version="2.19.0">
<artifact name="exoplayer-container-2.19.0.aar">
<sha256 value="f8d44b158d5f43a1da1912238c302b2c26ab8034344e94c5e58b673fd0327048" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-container-2.19.0.module">
<sha256 value="0509716ed4e8616622d26f44dde77f787c53e4038153579c4a52411a319d96e3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-core" version="2.19.0">
<artifact name="exoplayer-core-2.19.0.aar">
<sha256 value="da783e26eb7033ff33124185b50aee01f2b02234d12222b99e1e012e24c1f94d" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-core-2.19.0.module">
<sha256 value="8c7182e88880516b9e93a81033a5d8238be0381e41da0f462e48451c21e9526f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-database" version="2.19.0">
<artifact name="exoplayer-database-2.19.0.aar">
<sha256 value="e3b0a1b33f1392682bb59cc3c88e6ae02538a172880a4466542ba2ed902e8229" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-database-2.19.0.module">
<sha256 value="4e55bcf14ac65f9c06beb443a71de208b7203c7eb8f3c4dd1c51487a31db1e56" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-datasource" version="2.19.0">
<artifact name="exoplayer-datasource-2.19.0.aar">
<sha256 value="ae62f419c7da8a37530f2c507ba303cc8166ba4b56b34326b08ed6ea546a120d" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-datasource-2.19.0.module">
<sha256 value="d0e12555b89a63e505a472620341d1b1900520b9d2bc25b3c8530a9db79ffb82" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-decoder" version="2.19.0">
<artifact name="exoplayer-decoder-2.19.0.aar">
<sha256 value="1222e92cb18e041ed998f056f0226f5101dc22faa4768e63f9148f99484de3b6" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-decoder-2.19.0.module">
<sha256 value="d24ec7c397d53372866ff3e6886ec65788841863d676dea151094d5459918684" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-extractor" version="2.19.0">
<artifact name="exoplayer-extractor-2.19.0.aar">
<sha256 value="3e5536939c89220bfd844e332226fea9c1bd7d65ff2a51d7d613652744bb3f6c" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-extractor-2.19.0.module">
<sha256 value="73a7505d13669d60bdc962ec093c1b76c181b33ea27614a7468d32400793a521" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="exoplayer-ui" version="2.19.0">
<artifact name="exoplayer-ui-2.19.0.aar">
<sha256 value="0e95e2ef646a934a95b94f4fa0e50eec1fa420294b166d4a76bf116658a55459" origin="Generated by Gradle"/>
</artifact>
<artifact name="exoplayer-ui-2.19.0.module">
<sha256 value="538e820c54b958d661e1d8a9534c20081fda6a9992c23e6a39ad56d686e4e618" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.exoplayer" name="extension-mediasession" version="2.19.0">
<artifact name="extension-mediasession-2.19.0.aar">
<sha256 value="705148834535331c90b39836240d1cc9501423f762275ad3b784c1e88e2e94f6" origin="Generated by Gradle"/>
</artifact>
<artifact name="extension-mediasession-2.19.0.module">
<sha256 value="7c3e8f972b1c842fa99b8ca356ebbc70a2afda03ff827a50a456754e4c93e213" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.flexbox" name="flexbox" version="3.0.0">
<artifact name="flexbox-3.0.0.aar">
<sha256 value="5e19500486fd7c8e2e8c7aad6bbba9c8d0ada7057c6b32b9b5c61439814e7574" origin="Generated by Gradle"/>