diff --git a/app/build.gradle b/app/build.gradle index 7c25bba319..eecef55104 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -315,6 +315,7 @@ dependencies { implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1' implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1' + implementation 'com.google.android.exoplayer:extension-mediasession:2.9.1' implementation 'org.conscrypt:conscrypt-android:2.0.0' implementation 'org.signal:aesgcmprovider:0.0.3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1221f99d41..2ef7d3e906 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -541,6 +541,18 @@ + + + + + + + + + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index b77ab1d1bf..3c25624d00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms; +import android.net.Uri; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.Observer; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; @@ -51,6 +54,11 @@ public interface BindableConversationItem extends Unbindable { void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms); void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId); void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); + void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); + void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); + void onVoiceNotePause(@NonNull Uri uri); + void onVoiceNotePlay(@NonNull Uri uri, long messageId, long position); + void onVoiceNoteSeekTo(@NonNull Uri uri, long position); /** @return true if handled, false if you want to let the normal url handling continue */ boolean onUrlClicked(@NonNull String url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java deleted file mode 100644 index 7f19ed1b5f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ /dev/null @@ -1,378 +0,0 @@ -package org.thoughtcrime.securesms.audio; - -import android.content.Context; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import android.media.AudioManager; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.os.Message; -import android.os.PowerManager; -import android.os.PowerManager.WakeLock; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.Pair; -import android.widget.Toast; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.DefaultLoadControl; -import com.google.android.exoplayer2.DefaultRenderersFactory; -import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.ExoPlayerFactory; -import com.google.android.exoplayer2.LoadControl; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.audio.AudioAttributes; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; -import com.google.android.exoplayer2.extractor.ExtractorsFactory; -import com.google.android.exoplayer2.source.ExtractorMediaSource; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.mms.AudioSlide; -import org.thoughtcrime.securesms.util.ServiceUtil; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; -import org.whispersystems.libsignal.util.guava.Optional; - -import java.io.IOException; -import java.lang.ref.WeakReference; - -public class AudioSlidePlayer implements SensorEventListener { - - private static final String TAG = AudioSlidePlayer.class.getSimpleName(); - - private static @NonNull Optional playing = Optional.absent(); - - private final @NonNull Context context; - private final @NonNull AudioSlide slide; - private final @NonNull Handler progressEventHandler; - private final @NonNull AudioManager audioManager; - private final @NonNull SensorManager sensorManager; - private final @NonNull Sensor proximitySensor; - private final @Nullable WakeLock wakeLock; - - private @NonNull WeakReference listener; - private @Nullable SimpleExoPlayer mediaPlayer; - private long startTime; - - public synchronized static AudioSlidePlayer createFor(@NonNull Context context, - @NonNull AudioSlide slide, - @NonNull Listener listener) - { - if (playing.isPresent() && playing.get().getAudioSlide().equals(slide)) { - playing.get().setListener(listener); - return playing.get(); - } else { - return new AudioSlidePlayer(context, slide, listener); - } - } - - private AudioSlidePlayer(@NonNull Context context, - @NonNull AudioSlide slide, - @NonNull Listener listener) - { - this.context = context; - this.slide = slide; - this.listener = new WeakReference<>(listener); - this.progressEventHandler = new ProgressEventHandler(this); - this.audioManager = (AudioManager)context.getSystemService(Context.AUDIO_SERVICE); - this.sensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); - this.proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); - - if (Build.VERSION.SDK_INT >= 21) { - this.wakeLock = ServiceUtil.getPowerManager(context).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG); - } else { - this.wakeLock = null; - } - } - - public void play(final double progress) throws IOException { - play(progress, false); - } - - private void play(final double progress, boolean earpiece) throws IOException { - if (this.mediaPlayer != null) { - return; - } - - if (slide.getUri() == null) { - throw new IOException("Slide has no URI!"); - } - - LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).createDefaultLoadControl(); - this.mediaPlayer = ExoPlayerFactory.newSimpleInstance(context, new DefaultRenderersFactory(context), new DefaultTrackSelector(), loadControl); - this.startTime = System.currentTimeMillis(); - - mediaPlayer.prepare(createMediaSource(slide.getUri())); - mediaPlayer.setPlayWhenReady(true); - mediaPlayer.setAudioAttributes(new AudioAttributes.Builder() - .setContentType(earpiece ? C.CONTENT_TYPE_SPEECH : C.CONTENT_TYPE_MUSIC) - .setUsage(earpiece ? C.USAGE_VOICE_COMMUNICATION : C.USAGE_MEDIA) - .build()); - mediaPlayer.addListener(new Player.EventListener() { - - boolean started = false; - - @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - Log.d(TAG, "onPlayerStateChanged(" + playWhenReady + ", " + playbackState + ")"); - switch (playbackState) { - case Player.STATE_READY: - Log.i(TAG, "onPrepared() " + mediaPlayer.getBufferedPercentage() + "% buffered"); - synchronized (AudioSlidePlayer.this) { - if (mediaPlayer == null) return; - - if (started) { - Log.d(TAG, "Already started. Ignoring."); - return; - } - - started = true; - - if (progress > 0) { - mediaPlayer.seekTo((long) (mediaPlayer.getDuration() * progress)); - } - - sensorManager.registerListener(AudioSlidePlayer.this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL); - - setPlaying(AudioSlidePlayer.this); - } - - notifyOnStart(); - progressEventHandler.sendEmptyMessage(0); - break; - - case Player.STATE_ENDED: - Log.i(TAG, "onComplete"); - synchronized (AudioSlidePlayer.this) { - mediaPlayer = null; - - sensorManager.unregisterListener(AudioSlidePlayer.this); - - if (wakeLock != null && wakeLock.isHeld()) { - if (Build.VERSION.SDK_INT >= 21) { - wakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); - } - } - } - - notifyOnStop(); - progressEventHandler.removeMessages(0); - } - } - - @Override - public void onPlayerError(ExoPlaybackException error) { - Log.w(TAG, "MediaPlayer Error: " + error); - - Toast.makeText(context, R.string.AudioSlidePlayer_error_playing_audio, Toast.LENGTH_SHORT).show(); - - synchronized (AudioSlidePlayer.this) { - mediaPlayer = null; - - sensorManager.unregisterListener(AudioSlidePlayer.this); - - if (wakeLock != null && wakeLock.isHeld()) { - if (Build.VERSION.SDK_INT >= 21) { - wakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); - } - } - } - - notifyOnStop(); - progressEventHandler.removeMessages(0); - } - }); - } - - private MediaSource createMediaSource(@NonNull Uri uri) { - DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); - AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null); - ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true); - - return new ExtractorMediaSource.Factory(attachmentDataSourceFactory) - .setExtractorsFactory(extractorsFactory) - .createMediaSource(uri); - } - - public synchronized void stop() { - Log.i(TAG, "Stop called!"); - - removePlaying(this); - - if (this.mediaPlayer != null) { - this.mediaPlayer.stop(); - this.mediaPlayer.release(); - } - - sensorManager.unregisterListener(AudioSlidePlayer.this); - - this.mediaPlayer = null; - } - - public synchronized static void stopAll() { - if (playing.isPresent()) { - playing.get().stop(); - } - } - - public void setListener(@NonNull Listener listener) { - this.listener = new WeakReference<>(listener); - - if (this.mediaPlayer != null && this.mediaPlayer.getPlaybackState() == Player.STATE_READY) { - notifyOnStart(); - } - } - - public @NonNull AudioSlide getAudioSlide() { - return slide; - } - - - private Pair getProgress() { - if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) { - return new Pair<>(0D, 0); - } else { - return new Pair<>((double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(), - (int) mediaPlayer.getCurrentPosition()); - } - } - - private void notifyOnStart() { - Util.runOnMain(new Runnable() { - @Override - public void run() { - getListener().onStart(); - } - }); - } - - private void notifyOnStop() { - Util.runOnMain(new Runnable() { - @Override - public void run() { - getListener().onStop(); - } - }); - } - - private void notifyOnProgress(final double progress, final long millis) { - Util.runOnMain(new Runnable() { - @Override - public void run() { - getListener().onProgress(progress, millis); - } - }); - } - - private @NonNull Listener getListener() { - Listener listener = this.listener.get(); - - if (listener != null) return listener; - else return new Listener() { - @Override - public void onStart() {} - @Override - public void onStop() {} - @Override - public void onProgress(double progress, long millis) {} - }; - } - - private synchronized static void setPlaying(@NonNull AudioSlidePlayer player) { - if (playing.isPresent() && playing.get() != player) { - playing.get().notifyOnStop(); - playing.get().stop(); - } - - playing = Optional.of(player); - } - - private synchronized static void removePlaying(@NonNull AudioSlidePlayer player) { - if (playing.isPresent() && playing.get() == player) { - playing = Optional.absent(); - } - } - - @Override - public void onSensorChanged(SensorEvent event) { - if (event.sensor.getType() != Sensor.TYPE_PROXIMITY) return; - if (mediaPlayer == null || mediaPlayer.getPlaybackState() != Player.STATE_READY) return; - - int streamType; - - if (event.values[0] < 5f && event.values[0] != proximitySensor.getMaximumRange()) { - streamType = AudioManager.STREAM_VOICE_CALL; - } else { - streamType = AudioManager.STREAM_MUSIC; - } - - if (streamType == AudioManager.STREAM_VOICE_CALL && - mediaPlayer.getAudioStreamType() != streamType && - !audioManager.isWiredHeadsetOn()) - { - double position = mediaPlayer.getCurrentPosition(); - double duration = mediaPlayer.getDuration(); - double progress = position / duration; - - if (wakeLock != null) wakeLock.acquire(); - stop(); - try { - play(progress, true); - } catch (IOException e) { - Log.w(TAG, e); - } - } else if (streamType == AudioManager.STREAM_MUSIC && - mediaPlayer.getAudioStreamType() != streamType && - System.currentTimeMillis() - startTime > 500) - { - if (wakeLock != null) wakeLock.release(); - stop(); - notifyOnStop(); - } - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) { - - } - - public interface Listener { - void onStart(); - void onStop(); - void onProgress(double progress, long millis); - } - - private static class ProgressEventHandler extends Handler { - - private final WeakReference playerReference; - - private ProgressEventHandler(@NonNull AudioSlidePlayer player) { - this.playerReference = new WeakReference<>(player); - } - - @Override - public void handleMessage(Message msg) { - AudioSlidePlayer player = playerReference.get(); - - if (player == null || player.mediaPlayer == null || !isPlayerActive(player.mediaPlayer)) { - return; - } - - Pair progress = player.getProgress(); - player.notifyOnProgress(progress.first, progress.second); - sendEmptyMessageDelayed(0, 50); - } - - private boolean isPlayerActive(@NonNull SimpleExoPlayer player) { - return player.getPlaybackState() == Player.STATE_READY || player.getPlaybackState() == Player.STATE_BUFFERING; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java index 356fd4e76e..53d4f8a4ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/color/MaterialColor.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.color; import android.content.Context; +import android.content.res.Configuration; import android.graphics.Color; import androidx.annotation.ColorInt; @@ -8,6 +9,7 @@ import androidx.annotation.ColorRes; import androidx.annotation.NonNull; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ThemeUtil; import java.util.HashMap; import java.util.Map; @@ -68,6 +70,11 @@ public enum MaterialColor { this.serialized = serialized; } + public @ColorInt int toNotificationColor(@NonNull Context context) { + final boolean isDark = ThemeUtil.isDarkNotificationTheme(context); + return context.getResources().getColor(isDark ? shadeColor : mainColor); + } + public @ColorInt int toConversationColor(@NonNull Context context) { return context.getResources().getColor(mainColor); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java index 27cefe7780..2616f86e12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -5,6 +5,7 @@ import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.Rect; +import android.net.Uri; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; @@ -16,6 +17,8 @@ import android.widget.TextView; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.lifecycle.Observer; import com.airbnb.lottie.LottieAnimationView; import com.airbnb.lottie.LottieProperty; @@ -28,19 +31,17 @@ import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.audio.AudioSlidePlayer; import org.thoughtcrime.securesms.audio.AudioWaveForm; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.events.PartProgressEvent; -import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.SlideClickListener; -import java.io.IOException; import java.util.Objects; import java.util.concurrent.TimeUnit; -public final class AudioView extends FrameLayout implements AudioSlidePlayer.Listener { +public final class AudioView extends FrameLayout { private static final String TAG = AudioView.class.getSimpleName(); @@ -62,11 +63,14 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis @ColorInt private final int waveFormUnplayedBarsColor; @Nullable private SlideClickListener downloadListener; - @Nullable private AudioSlidePlayer audioSlidePlayer; private int backwardsCounter; private int lottieDirection; private boolean isPlaying; private long durationMillis; + private AudioSlide audioSlide; + private Callbacks callbacks; + + private final Observer playbackStateObserver = this::onPlaybackState; public AudioView(Context context) { this(context, null); @@ -122,11 +126,18 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis EventBus.getDefault().unregister(this); } + public Observer getPlaybackStateObserver() { + return playbackStateObserver; + } + public void setAudio(final @NonNull AudioSlide audio, + final @Nullable Callbacks callbacks, final boolean showControls) { + this.callbacks = callbacks; + if (seekBar instanceof WaveFormSeekBarView) { - if (audioSlidePlayer != null && !Objects.equals(audioSlidePlayer.getAudioSlide().getUri(), audio.getUri())) { + if (audioSlide != null && !Objects.equals(audioSlide.getUri(), audio.getUri())) { WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar; waveFormView.setWaveMode(false); seekBar.setProgress(0); @@ -147,12 +158,9 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis seekBar.setEnabled(true); if (circleProgress.isSpinning()) circleProgress.stopSpinning(); showPlayButton(); - lottieDirection = REVERSE; - playPauseButton.cancelAnimation(); - playPauseButton.setFrame(0); } - this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this); + this.audioSlide = audio; if (seekBar instanceof WaveFormSeekBarView) { WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar; @@ -177,24 +185,43 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis } } - public void cleanup() { - if (this.audioSlidePlayer != null && isPlaying) { - this.audioSlidePlayer.stop(); - } - } - public void setDownloadClickListener(@Nullable SlideClickListener listener) { this.downloadListener = listener; } - @Override - public void onStart() { + private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) { + onStart(voiceNotePlaybackState.getUri()); + onProgress(voiceNotePlaybackState.getUri(), + (double) voiceNotePlaybackState.getPlayheadPositionMillis() / durationMillis, + voiceNotePlaybackState.getPlayheadPositionMillis()); + } + + private void onStart(@NonNull Uri uri) { + if (!Objects.equals(uri, audioSlide.getUri())) { + if (audioSlide != null && audioSlide.getUri() != null) { + onStop(audioSlide.getUri()); + } + + return; + } + + if (isPlaying) { + return; + } + isPlaying = true; togglePlayToPause(); } - @Override - public void onStop() { + private void onStop(@NonNull Uri uri) { + if (!Objects.equals(uri, audioSlide.getUri())) { + return; + } + + if (!isPlaying) { + return; + } + isPlaying = false; togglePauseToPlay(); @@ -230,8 +257,11 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis this.downloadButton.setEnabled(enabled); } - @Override - public void onProgress(double progress, long millis) { + private void onProgress(@NonNull Uri uri, double progress, long millis) { + if (!Objects.equals(uri, audioSlide.getUri())) { + return; + } + int seekProgress = (int) Math.floor(progress * seekBar.getMax()); if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) { @@ -312,37 +342,27 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis } public void stopPlaybackAndReset() { - if (this.audioSlidePlayer != null && isPlaying) { - this.audioSlidePlayer.stop(); - togglePauseToPlay(); + if (audioSlide == null || audioSlide.getUri() == null) return; + + if (callbacks != null) { + callbacks.onStopAndReset(audioSlide.getUri()); + rewind(); } - rewind(); } private class PlayPauseClickedListener implements View.OnClickListener { @Override public void onClick(View v) { - if (lottieDirection == REVERSE) { - try { - Log.d(TAG, "playbutton onClick"); - if (audioSlidePlayer != null) { - togglePlayToPause(); - audioSlidePlayer.play(getProgress()); - } - } catch (IOException e) { - Log.w(TAG, e); + if (audioSlide == null || audioSlide.getUri() == null) return; + + if (callbacks != null) { + if (lottieDirection == REVERSE) { + callbacks.onPlay(audioSlide.getUri(), getPosition()); + } else { + callbacks.onPause(audioSlide.getUri()); } - } else { - Log.d(TAG, "pausebutton onClick"); - if (audioSlidePlayer != null) { - togglePauseToPlay(); - audioSlidePlayer.stop(); - if (autoRewind) { - rewind(); - } - } - } + }; } } @@ -351,6 +371,10 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis updateProgress(0, 0); } + private long getPosition() { + return (long) (getProgress() * durationMillis); + } + private class DownloadClickedListener implements View.OnClickListener { private final @NonNull AudioSlide slide; @@ -378,20 +402,24 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis @Override public synchronized void onStartTrackingTouch(SeekBar seekBar) { + if (audioSlide == null || audioSlide.getUri() == null) return; + wasPlaying = isPlaying; - if (audioSlidePlayer != null && isPlaying) { - audioSlidePlayer.stop(); + if (isPlaying) { + if (callbacks != null) { + callbacks.onPause(audioSlide.getUri()); + } } } @Override public synchronized void onStopTrackingTouch(SeekBar seekBar) { - try { - if (audioSlidePlayer != null && wasPlaying) { - audioSlidePlayer.play(getProgress()); + if (audioSlide == null || audioSlide.getUri() == null) return; + + if (callbacks != null) { + if (wasPlaying) { + callbacks.onSeekTo(audioSlide.getUri(), getPosition()); } - } catch (IOException e) { - Log.w(TAG, e); } } } @@ -405,9 +433,15 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) public void onEventAsync(final PartProgressEvent event) { - if (audioSlidePlayer != null && event.attachment.equals(audioSlidePlayer.getAudioSlide().asAttachment())) { + if (audioSlide != null && event.attachment.equals(audioSlide.asAttachment())) { circleProgress.setInstantProgress(((float) event.progress) / event.total); } } + public interface Callbacks { + void onPlay(@NonNull Uri audioUri, long position); + void onPause(@NonNull Uri audioUri); + void onSeekTo(@NonNull Uri audioUri, long position); + void onStopAndReset(@NonNull Uri audioUri); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java new file mode 100644 index 0000000000..38c7447ecc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java @@ -0,0 +1,232 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.content.ComponentName; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.RemoteException; +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.appcompat.app.AppCompatActivity; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.thoughtcrime.securesms.logging.Log; + +import java.util.Objects; + +/** + * Encapsulates control of voice note playback from an Activity component. + * + * This class assumes that it will be created within the scope of Activity#onCreate + * + * The workhorse of this repository is the ProgressEventHandler, which will supply a + * steady stream of update events to the set callback. + */ +public class VoiceNoteMediaController implements DefaultLifecycleObserver { + + public static final String EXTRA_MESSAGE_ID = "voice.note.message_id"; + public static final String EXTRA_PLAYHEAD = "voice.note.playhead"; + + private static final String TAG = Log.tag(VoiceNoteMediaController.class); + + private MediaBrowserCompat mediaBrowser; + private AppCompatActivity activity; + private ProgressEventHandler progressEventHandler; + private MutableLiveData voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE); + + private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback(); + + public VoiceNoteMediaController(@NonNull AppCompatActivity activity) { + this.activity = activity; + this.mediaBrowser = new MediaBrowserCompat(activity, + new ComponentName(activity, VoiceNotePlaybackService.class), + new ConnectionCallback(), + null); + + activity.getLifecycle().addObserver(this); + } + + public LiveData getVoiceNotePlaybackState() { + return voiceNotePlaybackState; + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + mediaBrowser.connect(); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) { + activity.setVolumeControlStream(AudioManager.STREAM_MUSIC); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + clearProgressEventHandler(); + + if (MediaControllerCompat.getMediaController(activity) != null) { + MediaControllerCompat.getMediaController(activity).unregisterCallback(mediaControllerCompatCallback); + } + mediaBrowser.disconnect(); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + 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 @NonNull MediaControllerCompat getMediaController() { + return MediaControllerCompat.getMediaController(activity); + } + + /** + * 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 position The desired position in milliseconds at which to start playback. + */ + public void startPlayback(@NonNull Uri audioSlideUri, long messageId, long position) { + if (isCurrentTrack(audioSlideUri)) { + getMediaController().getTransportControls().seekTo(position); + getMediaController().getTransportControls().play(); + } else { + Bundle extras = new Bundle(); + extras.putLong(EXTRA_MESSAGE_ID, messageId); + extras.putLong(EXTRA_PLAYHEAD, position); + + 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 (isCurrentTrack(audioSlideUri)) { + 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 position The position in milliseconds to seek to. + */ + public void seekToPosition(@NonNull Uri audioSlideUri, long position) { + if (isCurrentTrack(audioSlideUri)) { + getMediaController().getTransportControls().pause(); + getMediaController().getTransportControls().seekTo(position); + 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 (isCurrentTrack(audioSlideUri)) { + getMediaController().getTransportControls().stop(); + } + } + + private boolean isCurrentTrack(@NonNull Uri uri) { + MediaMetadataCompat metadataCompat = getMediaController().getMetadata(); + + return metadataCompat != null && Objects.equals(metadataCompat.getDescription().getMediaUri(), uri); + } + + private void notifyProgressEventHandler() { + if (progressEventHandler == null) { + progressEventHandler = new ProgressEventHandler(getMediaController(), voiceNotePlaybackState); + progressEventHandler.sendEmptyMessage(0); + } + } + + private void clearProgressEventHandler() { + if (progressEventHandler != null) { + progressEventHandler = null; + } + } + + private final class ConnectionCallback extends MediaBrowserCompat.ConnectionCallback { + @Override + public void onConnected() { + try { + MediaSessionCompat.Token token = mediaBrowser.getSessionToken(); + MediaControllerCompat mediaController = new MediaControllerCompat(activity, token); + + MediaControllerCompat.setMediaController(activity, mediaController); + + mediaController.registerCallback(mediaControllerCompatCallback); + + mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState()); + } catch (RemoteException e) { + Log.w(TAG, "onConnected: Failed to set media controller", e); + } + } + } + + private static class ProgressEventHandler extends Handler { + + private final MediaControllerCompat mediaController; + private final MutableLiveData voiceNotePlaybackState; + + private ProgressEventHandler(@NonNull MediaControllerCompat mediaController, + @NonNull MutableLiveData voiceNotePlaybackState) { + this.mediaController = mediaController; + this.voiceNotePlaybackState = voiceNotePlaybackState; + } + + @Override + public void handleMessage(Message msg) { + MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata(); + if (isPlayerActive(mediaController.getPlaybackState()) && + mediaMetadataCompat != null && + mediaMetadataCompat.getDescription() != null) + { + voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri()), + mediaController.getPlaybackState().getPosition())); + + sendEmptyMessageDelayed(0, 50); + } else { + voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE); + } + } + } + + private final class MediaControllerCompatCallback extends MediaControllerCompat.Callback { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + if (isPlayerActive(state)) { + notifyProgressEventHandler(); + } else { + clearProgressEventHandler(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java new file mode 100644 index 0000000000..d6781ad1a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java @@ -0,0 +1,83 @@ +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.WorkerThread; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +/** + * Factory responsible for building out MediaDescriptionCompat objects for voice notes. + */ +class VoiceNoteMediaDescriptionCompatFactory { + + public static final String EXTRA_MESSAGE_POSITION = "voice.note.extra.MESSAGE_POSITION"; + public static final String EXTRA_RECIPIENT_ID = "voice.note.extra.RECIPIENT_ID"; + public static final String EXTRA_THREAD_ID = "voice.note.extra.THREAD_ID"; + public static final String EXTRA_COLOR = "voice.note.extra.COLOR"; + + private static final String TAG = Log.tag(VoiceNoteMediaDescriptionCompatFactory.class); + + private VoiceNoteMediaDescriptionCompatFactory() {} + + /** + * Build out a MediaDescriptionCompat for a given voice note. Expects to be run + * on a background thread. + * + * @param context Context. + * @param uri The AudioSlide Uri of the given voice note. + * @param messageId The Message ID of the given voice note. + * + * @return A MediaDescriptionCompat with all the details the service expects. + */ + @WorkerThread + static MediaDescriptionCompat buildMediaDescription(@NonNull Context context, + @NonNull Uri uri, + long messageId) + { + final MessageRecord messageRecord; + try { + messageRecord = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + } catch (NoSuchMessageException e) { + Log.w(TAG, "buildMediaDescription: ", e); + return null; + } + + int startingPosition = DatabaseFactory.getMmsSmsDatabase(context) + .getMessagePositionInConversation(messageRecord.getThreadId(), + messageRecord.getDateReceived()); + + Bundle extras = new Bundle(); + extras.putString(EXTRA_RECIPIENT_ID, messageRecord.getIndividualRecipient().getId().serialize()); + extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition); + extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId()); + extras.putString(EXTRA_COLOR, messageRecord.getIndividualRecipient().getColor().serialize()); + + NotificationPrivacyPreference preference = TextSecurePreferences.getNotificationPrivacy(context); + + String title; + if (preference.isDisplayContact()) { + title = messageRecord.getIndividualRecipient().getDisplayName(context); + } else { + title = context.getString(R.string.MessageNotifier_signal_message); + } + + return new MediaDescriptionCompat.Builder() + .setMediaUri(uri) + .setTitle(title) + .setSubtitle(context.getString(R.string.ThreadRecord_voice_message)) + .setExtras(extras) + .build(); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java new file mode 100644 index 0000000000..681ae0ae62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.content.Context; +import android.support.v4.media.MediaDescriptionCompat; + +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; + +import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; + +/** + * This class is responsible for creating a MediaSource object for a given MediaDescriptionCompat + */ +final class VoiceNoteMediaSourceFactory implements TimelineQueueEditor.MediaSourceFactory { + + private final Context context; + + VoiceNoteMediaSourceFactory(Context context) { + this.context = context; + } + + /** + * Creates a MediaSource for a given MediaDescriptionCompat + * + * @param description The description to build from + * + * @return A preparable MediaSource + */ + @Override + public @Nullable MediaSource createMediaSource(MediaDescriptionCompat description) { + DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); + AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(context, defaultDataSourceFactory, null); + ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true); + + return new ExtractorMediaSource.Factory(attachmentDataSourceFactory) + .setExtractorsFactory(extractorsFactory) + .createMediaSource(description.getMediaUri()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java new file mode 100644 index 0000000000..5e05c02d13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.app.PendingIntent; +import android.content.Context; +import android.graphics.Bitmap; +import android.os.RemoteException; +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.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.database.ThreadDatabase; +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 org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +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; + + try { + controller = new MediaControllerCompat(context, token); + } catch (RemoteException e) { + throw new IllegalArgumentException("Could not create a controller with given token"); + } + + notificationManager = PlayerNotificationManager.createWithNotificationChannel(context, + NotificationChannels.OTHER, + R.string.NotificationChannel_other, + NOW_PLAYING_NOTIFICATION_ID, + new DescriptionAdapter()); + + notificationManager.setMediaSessionToken(token); + notificationManager.setSmallIcon(R.drawable.ic_signal_grey_24dp); + notificationManager.setRewindIncrementMs(0); + notificationManager.setFastForwardIncrementMs(0); + notificationManager.setNotificationListener(listener); + notificationManager.setColorized(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.requireNonNull(controller.getMetadata().getDescription().getTitle()).toString(); + } else { + return null; + } + } + + @Override + public @Nullable PendingIntent createCurrentContentIntent(Player player) { + if (!hasMetadata()) return null; + + RecipientId recipientId = RecipientId.from(Objects.requireNonNull(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_RECIPIENT_ID))); + int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION); + long threadId = controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID); + + MaterialColor color; + try { + color = MaterialColor.fromSerialized(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_COLOR)); + } catch (MaterialColor.UnknownColorException e) { + color = ContactColors.UNKNOWN_COLOR; + } + + notificationManager.setColor(color.toNotificationColor(context)); + + return PendingIntent.getActivity(context, + 0, + ConversationActivity.buildIntent(context, + recipientId, + threadId, + ThreadDatabase.DistributionTypes.DEFAULT, + startingPosition), + 0); + } + + @Override + public String getCurrentContentText(Player player) { + if (hasMetadata()) { + return Objects.requireNonNull(controller.getMetadata().getDescription().getSubtitle()).toString(); + } else { + return null; + } + } + + @Override + public @Nullable Bitmap getCurrentLargeIcon(Player player, PlayerNotificationManager.BitmapCallback callback) { + if (!hasMetadata() || !TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact()) { + cachedBitmap = null; + cachedRecipientId = null; + return null; + } + + RecipientId currentRecipientId = RecipientId.from(Objects.requireNonNull(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_RECIPIENT_ID))); + + if (Objects.equals(currentRecipientId, cachedRecipientId) && cachedBitmap != null) { + return cachedBitmap; + } else { + cachedRecipientId = currentRecipientId; + SignalExecutors.BOUNDED.execute(() -> { + try { + cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId)); + callback.onBitmap(cachedBitmap); + } catch (Exception e) { + cachedBitmap = null; + } + }); + + return null; + } + } + + private boolean hasMetadata() { + return controller.getMetadata() != null && controller.getMetadata().getDescription() != null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java new file mode 100644 index 0000000000..b0dea17b1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java @@ -0,0 +1,100 @@ +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.NonNull; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * 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 final Context context; + private final SimpleExoPlayer player; + private final VoiceNoteQueueDataAdapter queueDataAdapter; + private final TimelineQueueEditor.MediaSourceFactory mediaSourceFactory; + + VoiceNotePlaybackPreparer(@NonNull Context context, + @NonNull SimpleExoPlayer player, + @NonNull VoiceNoteQueueDataAdapter queueDataAdapter, + @NonNull TimelineQueueEditor.MediaSourceFactory mediaSourceFactory) + { + this.context = context; + this.player = player; + this.queueDataAdapter = queueDataAdapter; + this.mediaSourceFactory = mediaSourceFactory; + } + + @Override + public long getSupportedPrepareActions() { + return PlaybackStateCompat.ACTION_PLAY_FROM_URI; + } + + @Override + public void onPrepare() { + throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepare"); + } + + @Override + public void onPrepareFromMediaId(String mediaId, Bundle extras) { + throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromMediaId"); + } + + @Override + public void onPrepareFromSearch(String query, Bundle extras) { + throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromSearch"); + } + + @Override + public void onPrepareFromUri(Uri uri, Bundle extras) { + long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID); + long position = extras.getLong(VoiceNoteMediaController.EXTRA_PLAYHEAD, 0); + + SimpleTask.run(EXECUTOR, + () -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, uri, messageId), + description -> { + if (description == null) { + Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__could_not_start_playback, Toast.LENGTH_SHORT) + .show(); + Log.w(TAG, "onPrepareFromUri: could not start playback"); + return; + } + + queueDataAdapter.add(description); + player.seekTo(position); + player.prepare(Objects.requireNonNull(mediaSourceFactory.createMediaSource(description)), + position == 0, + false); + }); + } + + @Override + public String[] getCommands() { + return new String[0]; + } + + @Override + public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java new file mode 100644 index 0000000000..52a15fff9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -0,0 +1,227 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.os.Bundle; +import android.os.Process; +import android.os.RemoteException; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.MediaDescriptionCompat; +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.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.media.MediaBrowserServiceCompat; +import androidx.media.session.MediaButtonReceiver; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.ui.PlayerNotificationManager; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Android Service responsible for playback of voice notes. + */ +public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { + + private static final String TAG = Log.tag(VoiceNotePlaybackService.class); + private static final String EMPTY_ROOT_ID = "empty-root-id"; + + 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 PlaybackStateCompat.Builder stateBuilder; + private SimpleExoPlayer player; + private BecomingNoisyReceiver becomingNoisyReceiver; + private VoiceNoteNotificationManager voiceNoteNotificationManager; + private VoiceNoteQueueDataAdapter queueDataAdapter; + private boolean isForegroundService; + + private final LoadControl loadControl = new DefaultLoadControl.Builder() + .setBufferDurationsMs(Integer.MAX_VALUE, + Integer.MAX_VALUE, + Integer.MAX_VALUE, + Integer.MAX_VALUE) + .createDefaultLoadControl(); + + @Override + public void onCreate() { + super.onCreate(); + + mediaSession = new MediaSessionCompat(this, TAG); + stateBuilder = new PlaybackStateCompat.Builder() + .setActions(SUPPORTED_ACTIONS); + mediaSessionConnector = new MediaSessionConnector(mediaSession, null); + becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken()); + player = ExoPlayerFactory.newSimpleInstance(this, new DefaultRenderersFactory(this), new DefaultTrackSelector(), loadControl); + queueDataAdapter = new VoiceNoteQueueDataAdapter(); + voiceNoteNotificationManager = new VoiceNoteNotificationManager(this, + mediaSession.getSessionToken(), + new VoiceNoteNotificationManagerListener()); + + VoiceNoteMediaSourceFactory mediaSourceFactory = new VoiceNoteMediaSourceFactory(this); + + mediaSession.setPlaybackState(stateBuilder.build()); + + player.addListener(new VoiceNotePlayerEventListener()); + player.setAudioAttributes(new AudioAttributes.Builder() + .setContentType(C.CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_MEDIA) + .build()); + + mediaSessionConnector.setPlayer(player, new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory)); + mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession, queueDataAdapter)); + + setSessionToken(mediaSession.getSessionToken()); + + mediaSession.setActive(true); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + super.onTaskRemoved(rootIntent); + + player.stop(true); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mediaSession.setActive(false); + mediaSession.release(); + becomingNoisyReceiver.unregister(); + player.release(); + } + + @Override + public @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) { + if (clientUid == Process.myUid()) { + return new BrowserRoot(EMPTY_ROOT_ID, null); + } else { + return null; + } + } + + @Override + public void onLoadChildren(@NonNull String parentId, @NonNull Result> result) { + result.sendResult(Collections.emptyList()); + } + + private class VoiceNotePlayerEventListener implements Player.EventListener { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + switch (playbackState) { + case Player.STATE_BUFFERING: + case Player.STATE_READY: + voiceNoteNotificationManager.showNotification(player); + + if (!playWhenReady) { + stopForeground(false); + becomingNoisyReceiver.unregister(); + } else { + becomingNoisyReceiver.register(); + } + break; + default: + becomingNoisyReceiver.unregister(); + voiceNoteNotificationManager.hideNotification(); + } + } + } + + private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener { + + @Override + public void onNotificationStarted(int notificationId, Notification notification) { + if (!isForegroundService) { + ContextCompat.startForegroundService(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class)); + startForeground(notificationId, notification); + isForegroundService = true; + } + } + + @Override + public void onNotificationCancelled(int notificationId) { + stopForeground(true); + isForegroundService = false; + stopSelf(); + } + } + + /** + * 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; + try { + this.controller = new MediaControllerCompat(context, token); + } catch (RemoteException e) { + throw new IllegalArgumentException("Failed to create controller from token", e); + } + } + + 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(); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java new file mode 100644 index 0000000000..75b2537930 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +/** + * Domain-level state object representing the state of the currently playing voice note. + */ +public class VoiceNotePlaybackState { + + public static final VoiceNotePlaybackState NONE = new VoiceNotePlaybackState(Uri.EMPTY, 0); + + private final Uri uri; + private final long playheadPositionMillis; + + public VoiceNotePlaybackState(@NonNull Uri uri, long playheadPositionMillis) { + this.uri = uri; + this.playheadPositionMillis = playheadPositionMillis; + } + + /** + * @return Uri of the currently playing AudioSlide + */ + public Uri getUri() { + return uri; + } + + /** + * @return The last known playhead position + */ + public long getPlayheadPositionMillis() { + return playheadPositionMillis; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java new file mode 100644 index 0000000000..c0a971bdd4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.components.voice; + +import android.net.Uri; +import android.support.v4.media.MediaDescriptionCompat; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; + +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +/** + * DataAdapter which maintains the current queue of MediaDescriptionCompat objects. + */ +final class VoiceNoteQueueDataAdapter implements TimelineQueueEditor.QueueDataAdapter { + + private final List descriptions = new LinkedList<>(); + + @Override + public MediaDescriptionCompat getMediaDescription(int position) { + return descriptions.get(position); + } + + @Override + public void add(int position, MediaDescriptionCompat description) { + descriptions.add(position, description); + } + + @Override + public void remove(int position) { + descriptions.remove(position); + } + + @Override + public void move(int from, int to) { + MediaDescriptionCompat description = descriptions.remove(from); + descriptions.add(to, description); + } + + void add(MediaDescriptionCompat description) { + descriptions.add(description); + } + + int indexOf(@NonNull Uri uri) { + for (int i = 0; i < descriptions.size(); i++) { + if (Objects.equals(uri, descriptions.get(i).getMediaUri())) { + return i; + } + } + + return -1; + } + + void clear() { + descriptions.clear(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java new file mode 100644 index 0000000000..b4b86ab13b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueNavigator.java @@ -0,0 +1,28 @@ +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.Player; +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator; + +/** + * Navigator to help support seek forward and back. + */ +final class VoiceNoteQueueNavigator extends TimelineQueueNavigator { + + private final TimelineQueueEditor.QueueDataAdapter queueDataAdapter; + + public VoiceNoteQueueNavigator(@NonNull MediaSessionCompat mediaSession, @NonNull TimelineQueueEditor.QueueDataAdapter queueDataAdapter) { + super(mediaSession); + this.queueDataAdapter = queueDataAdapter; + } + + @Override + public MediaDescriptionCompat getMediaDescription(Player player, int windowIndex) { + return queueDataAdapter.getMediaDescription(windowIndex); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 8beb3c9caf..0295f58859 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -98,7 +98,6 @@ import org.thoughtcrime.securesms.VerifyIdentityActivity; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.TombstoneAttachment; import org.thoughtcrime.securesms.audio.AudioRecorder; -import org.thoughtcrime.securesms.audio.AudioSlidePlayer; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.components.AnimatingToggle; import org.thoughtcrime.securesms.components.ComposeText; @@ -557,7 +556,6 @@ public class ConversationActivity extends PassphraseRequiredActivity fragment.setLastSeen(System.currentTimeMillis()); markLastSeen(); - AudioSlidePlayer.stopAll(); EventBus.getDefault().unregister(this); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 7616175c10..e52fc9c32d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -52,6 +52,7 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityOptionsCompat; import androidx.core.text.HtmlCompat; +import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -70,6 +71,8 @@ import org.thoughtcrime.securesms.components.ConversationScrollToView; import org.thoughtcrime.securesms.components.ConversationTypingView; import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity; @@ -179,6 +182,7 @@ public class ConversationFragment extends LoggingFragment { private Animation mentionButtonOutAnimation; private OnScrollListener conversationScrollListener; private int pulsePosition = -1; + private VoiceNoteMediaController voiceNoteMediaController; public static void prepare(@NonNull Context context) { FrameLayout parent = new FrameLayout(context); @@ -306,6 +310,7 @@ public class ConversationFragment extends LoggingFragment { initializeResources(); initializeMessageRequestViewModel(); initializeListAdapter(); + voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity()); } @Override @@ -1355,6 +1360,31 @@ public class ConversationFragment extends LoggingFragment { listener.onMessageWithErrorClicked(messageRecord); } + @Override + public void onVoiceNotePause(@NonNull Uri uri) { + voiceNoteMediaController.pausePlayback(uri); + } + + @Override + public void onVoiceNotePlay(@NonNull Uri uri, long messageId, long position) { + voiceNoteMediaController.startPlayback(uri, messageId, position); + } + + @Override + public void onVoiceNoteSeekTo(@NonNull Uri uri, long position) { + voiceNoteMediaController.seekToPosition(uri, position); + } + + @Override + public void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { + voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), onPlaybackStartObserver); + } + + @Override + public void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { + voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver); + } + @Override public boolean onUrlClicked(@NonNull String url) { return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index e238be817b..3fceaef4f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -407,6 +407,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati conversationRecipient.removeForeverObserver(this); } cancelPulseOutlinerAnimation(); + if (eventListener != null && audioViewStub.resolved()) { + eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver()); + } } public ConversationMessage getConversationMessage() { @@ -655,6 +658,10 @@ public class ConversationItem extends LinearLayout implements BindableConversati { boolean showControls = !messageRecord.isFailed(); + if (eventListener != null && audioViewStub.resolved()) { + eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver()); + } + if (isViewOnceMessage(messageRecord) && !messageRecord.isRemoteDelete()) { revealableStub.get().setVisibility(VISIBLE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); @@ -738,10 +745,14 @@ public class ConversationItem extends LinearLayout implements BindableConversati if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE); //noinspection ConstantConditions - audioViewStub.get().setAudio(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide(), showControls); + audioViewStub.get().setAudio(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide(), new AudioViewCallbacks(), showControls); audioViewStub.get().setDownloadClickListener(singleDownloadClickListener); audioViewStub.get().setOnLongClickListener(passthroughClickListener); + if (eventListener != null) { + eventListener.onRegisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver()); + } + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -1534,6 +1545,35 @@ public class ConversationItem extends LinearLayout implements BindableConversati public void updateDrawState(@NonNull TextPaint ds) { } } + private final class AudioViewCallbacks implements AudioView.Callbacks { + + @Override + public void onPlay(@NonNull Uri audioUri, long position) { + if (eventListener == null) return; + + eventListener.onVoiceNotePlay(audioUri, messageRecord.getId(), position); + } + + @Override + public void onPause(@NonNull Uri audioUri) { + if (eventListener == null) return; + + eventListener.onVoiceNotePause(audioUri); + } + + @Override + public void onSeekTo(@NonNull Uri audioUri, long position) { + if (eventListener == null) return; + + eventListener.onVoiceNoteSeekTo(audioUri, position); + } + + @Override + public void onStopAndReset(@NonNull Uri audioUri) { + throw new UnsupportedOperationException(); + } + } + private void handleMessageApproval() { final int title; final int message; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java index a2fd7a5cd7..f4fac55797 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java @@ -17,6 +17,7 @@ package org.thoughtcrime.securesms.mediaoverview; import android.content.Context; +import android.net.Uri; import android.os.Handler; import android.view.LayoutInflater; import android.view.View; @@ -63,6 +64,7 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { private final GlideRequests glideRequests; private final ItemClickListener itemClickListener; private final Map selected = new HashMap<>(); + private final AudioView.Callbacks audioViewCallbacks; private GroupedThreadMedia media; private boolean showFileSizes; @@ -73,12 +75,6 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { private static final int GALLERY_DETAIL = 3; private static final int DOCUMENT_DETAIL = 4; - void pause(RecyclerView.ViewHolder holder) { - if (holder instanceof AudioDetailViewHolder) { - ((AudioDetailViewHolder) holder).pause(); - } - } - void detach(RecyclerView.ViewHolder holder) { if (holder instanceof SelectableViewHolder) { ((SelectableViewHolder) holder).onDetached(); @@ -98,15 +94,17 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { @NonNull GlideRequests glideRequests, GroupedThreadMedia media, ItemClickListener clickListener, + @NonNull AudioView.Callbacks audioViewCallbacks, boolean showFileSizes, boolean showThread) { - this.context = context; - this.glideRequests = glideRequests; - this.media = media; - this.itemClickListener = clickListener; - this.showFileSizes = showFileSizes; - this.showThread = showThread; + this.context = context; + this.glideRequests = glideRequests; + this.media = media; + this.itemClickListener = clickListener; + this.audioViewCallbacks = audioViewCallbacks; + this.showFileSizes = showFileSizes; + this.showThread = showThread; } public void setMedia(GroupedThreadMedia media) { @@ -437,25 +435,15 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { throw new AssertionError(); } - audioView.setAudio((AudioSlide) slide, true); + audioView.setAudio((AudioSlide) slide, audioViewCallbacks, true); audioView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); } - @Override - void unbind() { - audioView.stopPlaybackAndReset(); - super.unbind(); - } - @Override protected String getFileTypeDescription(@NonNull Context context, @NonNull Slide slide) { return context.getString(R.string.MediaOverviewActivity_audio); } - - public void pause() { - audioView.stopPlaybackAndReset(); - } } private class GalleryDetailViewHolder extends DetailViewHolder { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java index c07ef1d2ab..3dc535c0a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java @@ -17,6 +17,7 @@ import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; import androidx.fragment.app.Fragment; @@ -30,6 +31,8 @@ import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; import org.thoughtcrime.securesms.database.MediaDatabase; import org.thoughtcrime.securesms.database.loaders.GroupedThreadMediaLoader; import org.thoughtcrime.securesms.database.loaders.MediaLoader; @@ -41,6 +44,7 @@ import org.thoughtcrime.securesms.util.Util; public final class MediaOverviewPageFragment extends Fragment implements MediaGalleryAllAdapter.ItemClickListener, + AudioView.Callbacks, LoaderManager.LoaderCallbacks { @@ -61,6 +65,7 @@ public final class MediaOverviewPageFragment extends Fragment private boolean detail; private MediaGalleryAllAdapter adapter; private GridMode gridMode; + private VoiceNoteMediaController voiceNoteMediaController; public static @NonNull Fragment newInstance(long threadId, @NonNull MediaLoader.MediaType mediaType, @@ -91,6 +96,13 @@ public final class MediaOverviewPageFragment extends Fragment LoaderManager.getInstance(this).initLoader(0, null, this); } + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity()); + } + @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { Context context = requireContext(); @@ -104,6 +116,7 @@ public final class MediaOverviewPageFragment extends Fragment GlideApp.with(this), new GroupedThreadMediaLoader.EmptyGroupedThreadMedia(), this, + this, sorting.isRelatedToFileSize(), threadId == MediaDatabase.ALL_THREADS); this.recyclerView.setAdapter(adapter); @@ -180,15 +193,6 @@ public final class MediaOverviewPageFragment extends Fragment } } - @Override - public void onPause() { - super.onPause(); - int childCount = recyclerView.getChildCount(); - for (int i = 0; i < childCount; i++) { - adapter.pause(recyclerView.getChildViewHolder(recyclerView.getChildAt(i))); - } - } - @Override public void onDestroy() { super.onDestroy(); @@ -305,6 +309,26 @@ public final class MediaOverviewPageFragment extends Fragment ((MediaOverviewActivity) activity).onEnterMultiSelect(); } + @Override + public void onPlay(@NonNull Uri audioUri, long position) { + voiceNoteMediaController.startPlayback(audioUri, -1, position); + } + + @Override + public void onPause(@NonNull Uri audioUri) { + voiceNoteMediaController.pausePlayback(audioUri); + } + + @Override + public void onSeekTo(@NonNull Uri audioUri, long position) { + voiceNoteMediaController.seekToPosition(audioUri, position); + } + + @Override + public void onStopAndReset(@NonNull Uri audioUri) { + voiceNoteMediaController.stopPlaybackAndReset(audioUri); + } + private class ActionModeCallback implements ActionMode.Callback { private int originalStatusBarColor; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index ea205105d1..5e7eb8e101 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -142,8 +142,6 @@ public class AttachmentManager { markGarbage(getSlideUri()); slide = Optional.absent(); - - audioView.cleanup(); } } @@ -279,7 +277,7 @@ public class AttachmentManager { attachmentViewStub.get().setVisibility(View.VISIBLE); if (slide.hasAudio()) { - audioView.setAudio((AudioSlide) slide, false); + audioView.setAudio((AudioSlide) slide, null, false); removableMediaView.display(audioView, false); result.set(true); } else if (slide.hasDocument()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 42b169098e..442a7d0720 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -93,6 +93,15 @@ public final class AvatarUtil { } } + @WorkerThread + public static Bitmap getBitmapForNotification(@NonNull Context context, @NonNull Recipient recipient) { + try { + return requestCircle(GlideApp.with(context).asBitmap(), context, recipient).submit().get(); + } catch (ExecutionException | InterruptedException e) { + return null; + } + } + public static GlideRequest getSelfAvatarOrFallbackIcon(@NonNull Context context, @DrawableRes int fallbackIcon) { return GlideApp.with(context) .asDrawable() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java index a3dfd63e87..527a8e517d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ThemeUtil.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util; import android.content.Context; +import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Color; import androidx.annotation.AttrRes; @@ -21,6 +22,10 @@ import org.thoughtcrime.securesms.R; public class ThemeUtil { + public static boolean isDarkNotificationTheme(@NonNull Context context) { + return (context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + public static boolean isDarkTheme(@NonNull Context context) { return getAttribute(context, R.attr.theme_type, "light").equals("dark"); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7910432573..a135d69098 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2691,6 +2691,7 @@ Share Copied to clipboard The link is not currently active + Could not start playback. diff --git a/app/witness-verifications.gradle b/app/witness-verifications.gradle index 0880746e8a..6dfc9bc588 100644 --- a/app/witness-verifications.gradle +++ b/app/witness-verifications.gradle @@ -255,6 +255,9 @@ dependencyVerification { ['com.google.android.exoplayer:exoplayer-ui:2.9.1', '7a942afcc402ff01e9bf48e8d3942850986710f06562d50a1408aaf04a683151'], + ['com.google.android.exoplayer:extension-mediasession:2.9.1', + 'a7dff433b41b714c6584d925f1438ca890eaa16d66a47c3fb9ce88b39dcf3d52'], + ['com.google.android.gms:play-services-auth-api-phone:16.0.0', '19365818b9ceb048ef48db12b5ffadd5eb86dbeb2c7c7b823bfdd89c665f42e5'],