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'],