Allow voice notes to continue playback after leaving conversation.
This commit is contained in:
parent
7ef57cc0cf
commit
9effa47dd8
25 changed files with 1211 additions and 469 deletions
|
@ -315,6 +315,7 @@ dependencies {
|
||||||
|
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
|
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: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.conscrypt:conscrypt-android:2.0.0'
|
||||||
implementation 'org.signal:aesgcmprovider:0.0.3'
|
implementation 'org.signal:aesgcmprovider:0.0.3'
|
||||||
|
|
|
@ -541,6 +541,18 @@
|
||||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||||
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
||||||
|
|
||||||
|
<service android:name=".components.voice.VoiceNotePlaybackService">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<service android:name=".service.QuickResponseService"
|
<service android:name=".service.QuickResponseService"
|
||||||
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
|
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
|
||||||
android:exported="true" >
|
android:exported="true" >
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
package org.thoughtcrime.securesms;
|
package org.thoughtcrime.securesms;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.lifecycle.LifecycleOwner;
|
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.contactshare.Contact;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
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 onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
|
||||||
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||||
|
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||||
|
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> 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 */
|
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||||
boolean onUrlClicked(@NonNull String url);
|
boolean onUrlClicked(@NonNull String url);
|
||||||
|
|
|
@ -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<AudioSlidePlayer> 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> 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<Double, Integer> 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<AudioSlidePlayer> 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<Double, Integer> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,7 @@
|
||||||
package org.thoughtcrime.securesms.color;
|
package org.thoughtcrime.securesms.color;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.res.Configuration;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
|
|
||||||
import androidx.annotation.ColorInt;
|
import androidx.annotation.ColorInt;
|
||||||
|
@ -8,6 +9,7 @@ import androidx.annotation.ColorRes;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
|
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -68,6 +70,11 @@ public enum MaterialColor {
|
||||||
this.serialized = serialized;
|
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) {
|
public @ColorInt int toConversationColor(@NonNull Context context) {
|
||||||
return context.getResources().getColor(mainColor);
|
return context.getResources().getColor(mainColor);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import android.content.res.TypedArray;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
import android.graphics.PorterDuff;
|
import android.graphics.PorterDuff;
|
||||||
import android.graphics.Rect;
|
import android.graphics.Rect;
|
||||||
|
import android.net.Uri;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.view.MotionEvent;
|
import android.view.MotionEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
@ -16,6 +17,8 @@ import android.widget.TextView;
|
||||||
import androidx.annotation.ColorInt;
|
import androidx.annotation.ColorInt;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.util.Consumer;
|
||||||
|
import androidx.lifecycle.Observer;
|
||||||
|
|
||||||
import com.airbnb.lottie.LottieAnimationView;
|
import com.airbnb.lottie.LottieAnimationView;
|
||||||
import com.airbnb.lottie.LottieProperty;
|
import com.airbnb.lottie.LottieProperty;
|
||||||
|
@ -28,19 +31,17 @@ import org.greenrobot.eventbus.EventBus;
|
||||||
import org.greenrobot.eventbus.Subscribe;
|
import org.greenrobot.eventbus.Subscribe;
|
||||||
import org.greenrobot.eventbus.ThreadMode;
|
import org.greenrobot.eventbus.ThreadMode;
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
|
|
||||||
import org.thoughtcrime.securesms.audio.AudioWaveForm;
|
import org.thoughtcrime.securesms.audio.AudioWaveForm;
|
||||||
|
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
|
||||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.TimeUnit;
|
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();
|
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;
|
@ColorInt private final int waveFormUnplayedBarsColor;
|
||||||
|
|
||||||
@Nullable private SlideClickListener downloadListener;
|
@Nullable private SlideClickListener downloadListener;
|
||||||
@Nullable private AudioSlidePlayer audioSlidePlayer;
|
|
||||||
private int backwardsCounter;
|
private int backwardsCounter;
|
||||||
private int lottieDirection;
|
private int lottieDirection;
|
||||||
private boolean isPlaying;
|
private boolean isPlaying;
|
||||||
private long durationMillis;
|
private long durationMillis;
|
||||||
|
private AudioSlide audioSlide;
|
||||||
|
private Callbacks callbacks;
|
||||||
|
|
||||||
|
private final Observer<VoiceNotePlaybackState> playbackStateObserver = this::onPlaybackState;
|
||||||
|
|
||||||
public AudioView(Context context) {
|
public AudioView(Context context) {
|
||||||
this(context, null);
|
this(context, null);
|
||||||
|
@ -122,11 +126,18 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||||
EventBus.getDefault().unregister(this);
|
EventBus.getDefault().unregister(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||||
|
return playbackStateObserver;
|
||||||
|
}
|
||||||
|
|
||||||
public void setAudio(final @NonNull AudioSlide audio,
|
public void setAudio(final @NonNull AudioSlide audio,
|
||||||
|
final @Nullable Callbacks callbacks,
|
||||||
final boolean showControls)
|
final boolean showControls)
|
||||||
{
|
{
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
|
||||||
if (seekBar instanceof WaveFormSeekBarView) {
|
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;
|
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
|
||||||
waveFormView.setWaveMode(false);
|
waveFormView.setWaveMode(false);
|
||||||
seekBar.setProgress(0);
|
seekBar.setProgress(0);
|
||||||
|
@ -147,12 +158,9 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||||
seekBar.setEnabled(true);
|
seekBar.setEnabled(true);
|
||||||
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||||
showPlayButton();
|
showPlayButton();
|
||||||
lottieDirection = REVERSE;
|
|
||||||
playPauseButton.cancelAnimation();
|
|
||||||
playPauseButton.setFrame(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
|
this.audioSlide = audio;
|
||||||
|
|
||||||
if (seekBar instanceof WaveFormSeekBarView) {
|
if (seekBar instanceof WaveFormSeekBarView) {
|
||||||
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
|
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) {
|
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
|
||||||
this.downloadListener = listener;
|
this.downloadListener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) {
|
||||||
public void onStart() {
|
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;
|
isPlaying = true;
|
||||||
togglePlayToPause();
|
togglePlayToPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void onStop(@NonNull Uri uri) {
|
||||||
public void onStop() {
|
if (!Objects.equals(uri, audioSlide.getUri())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
togglePauseToPlay();
|
togglePauseToPlay();
|
||||||
|
|
||||||
|
@ -230,8 +257,11 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||||
this.downloadButton.setEnabled(enabled);
|
this.downloadButton.setEnabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void onProgress(@NonNull Uri uri, double progress, long millis) {
|
||||||
public void onProgress(double progress, long millis) {
|
if (!Objects.equals(uri, audioSlide.getUri())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int seekProgress = (int) Math.floor(progress * seekBar.getMax());
|
int seekProgress = (int) Math.floor(progress * seekBar.getMax());
|
||||||
|
|
||||||
if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) {
|
if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) {
|
||||||
|
@ -312,37 +342,27 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stopPlaybackAndReset() {
|
public void stopPlaybackAndReset() {
|
||||||
if (this.audioSlidePlayer != null && isPlaying) {
|
if (audioSlide == null || audioSlide.getUri() == null) return;
|
||||||
this.audioSlidePlayer.stop();
|
|
||||||
togglePauseToPlay();
|
if (callbacks != null) {
|
||||||
}
|
callbacks.onStopAndReset(audioSlide.getUri());
|
||||||
rewind();
|
rewind();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class PlayPauseClickedListener implements View.OnClickListener {
|
private class PlayPauseClickedListener implements View.OnClickListener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
|
if (audioSlide == null || audioSlide.getUri() == null) return;
|
||||||
|
|
||||||
|
if (callbacks != null) {
|
||||||
if (lottieDirection == REVERSE) {
|
if (lottieDirection == REVERSE) {
|
||||||
try {
|
callbacks.onPlay(audioSlide.getUri(), getPosition());
|
||||||
Log.d(TAG, "playbutton onClick");
|
|
||||||
if (audioSlidePlayer != null) {
|
|
||||||
togglePlayToPause();
|
|
||||||
audioSlidePlayer.play(getProgress());
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "pausebutton onClick");
|
callbacks.onPause(audioSlide.getUri());
|
||||||
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);
|
updateProgress(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long getPosition() {
|
||||||
|
return (long) (getProgress() * durationMillis);
|
||||||
|
}
|
||||||
|
|
||||||
private class DownloadClickedListener implements View.OnClickListener {
|
private class DownloadClickedListener implements View.OnClickListener {
|
||||||
private final @NonNull AudioSlide slide;
|
private final @NonNull AudioSlide slide;
|
||||||
|
|
||||||
|
@ -378,20 +402,24 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void onStartTrackingTouch(SeekBar seekBar) {
|
public synchronized void onStartTrackingTouch(SeekBar seekBar) {
|
||||||
|
if (audioSlide == null || audioSlide.getUri() == null) return;
|
||||||
|
|
||||||
wasPlaying = isPlaying;
|
wasPlaying = isPlaying;
|
||||||
if (audioSlidePlayer != null && isPlaying) {
|
if (isPlaying) {
|
||||||
audioSlidePlayer.stop();
|
if (callbacks != null) {
|
||||||
|
callbacks.onPause(audioSlide.getUri());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void onStopTrackingTouch(SeekBar seekBar) {
|
public synchronized void onStopTrackingTouch(SeekBar seekBar) {
|
||||||
try {
|
if (audioSlide == null || audioSlide.getUri() == null) return;
|
||||||
if (audioSlidePlayer != null && wasPlaying) {
|
|
||||||
audioSlidePlayer.play(getProgress());
|
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)
|
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||||
public void onEventAsync(final PartProgressEvent event) {
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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> 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<VoiceNotePlaybackState> 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> voiceNotePlaybackState;
|
||||||
|
|
||||||
|
private ProgressEventHandler(@NonNull MediaControllerCompat mediaController,
|
||||||
|
@NonNull MutableLiveData<VoiceNotePlaybackState> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<List<MediaBrowserCompat.MediaItem>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<MediaDescriptionCompat> 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -98,7 +98,6 @@ import org.thoughtcrime.securesms.VerifyIdentityActivity;
|
||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
|
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
|
||||||
import org.thoughtcrime.securesms.audio.AudioRecorder;
|
import org.thoughtcrime.securesms.audio.AudioRecorder;
|
||||||
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
|
|
||||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||||
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
||||||
import org.thoughtcrime.securesms.components.ComposeText;
|
import org.thoughtcrime.securesms.components.ComposeText;
|
||||||
|
@ -557,7 +556,6 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
|
|
||||||
fragment.setLastSeen(System.currentTimeMillis());
|
fragment.setLastSeen(System.currentTimeMillis());
|
||||||
markLastSeen();
|
markLastSeen();
|
||||||
AudioSlidePlayer.stopAll();
|
|
||||||
EventBus.getDefault().unregister(this);
|
EventBus.getDefault().unregister(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,7 @@ import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
import androidx.core.app.ActivityOptionsCompat;
|
import androidx.core.app.ActivityOptionsCompat;
|
||||||
import androidx.core.text.HtmlCompat;
|
import androidx.core.text.HtmlCompat;
|
||||||
|
import androidx.lifecycle.Observer;
|
||||||
import androidx.lifecycle.ViewModelProviders;
|
import androidx.lifecycle.ViewModelProviders;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
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.ConversationTypingView;
|
||||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
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.Contact;
|
||||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||||
import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
|
import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
|
||||||
|
@ -179,6 +182,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
private Animation mentionButtonOutAnimation;
|
private Animation mentionButtonOutAnimation;
|
||||||
private OnScrollListener conversationScrollListener;
|
private OnScrollListener conversationScrollListener;
|
||||||
private int pulsePosition = -1;
|
private int pulsePosition = -1;
|
||||||
|
private VoiceNoteMediaController voiceNoteMediaController;
|
||||||
|
|
||||||
public static void prepare(@NonNull Context context) {
|
public static void prepare(@NonNull Context context) {
|
||||||
FrameLayout parent = new FrameLayout(context);
|
FrameLayout parent = new FrameLayout(context);
|
||||||
|
@ -306,6 +310,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
initializeResources();
|
initializeResources();
|
||||||
initializeMessageRequestViewModel();
|
initializeMessageRequestViewModel();
|
||||||
initializeListAdapter();
|
initializeListAdapter();
|
||||||
|
voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -1355,6 +1360,31 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
listener.onMessageWithErrorClicked(messageRecord);
|
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<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||||
|
voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), onPlaybackStartObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver) {
|
||||||
|
voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onUrlClicked(@NonNull String url) {
|
public boolean onUrlClicked(@NonNull String url) {
|
||||||
return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url);
|
return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url);
|
||||||
|
|
|
@ -407,6 +407,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||||
conversationRecipient.removeForeverObserver(this);
|
conversationRecipient.removeForeverObserver(this);
|
||||||
}
|
}
|
||||||
cancelPulseOutlinerAnimation();
|
cancelPulseOutlinerAnimation();
|
||||||
|
if (eventListener != null && audioViewStub.resolved()) {
|
||||||
|
eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ConversationMessage getConversationMessage() {
|
public ConversationMessage getConversationMessage() {
|
||||||
|
@ -655,6 +658,10 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||||
{
|
{
|
||||||
boolean showControls = !messageRecord.isFailed();
|
boolean showControls = !messageRecord.isFailed();
|
||||||
|
|
||||||
|
if (eventListener != null && audioViewStub.resolved()) {
|
||||||
|
eventListener.onUnregisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver());
|
||||||
|
}
|
||||||
|
|
||||||
if (isViewOnceMessage(messageRecord) && !messageRecord.isRemoteDelete()) {
|
if (isViewOnceMessage(messageRecord) && !messageRecord.isRemoteDelete()) {
|
||||||
revealableStub.get().setVisibility(VISIBLE);
|
revealableStub.get().setVisibility(VISIBLE);
|
||||||
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE);
|
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);
|
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||||
|
|
||||||
//noinspection ConstantConditions
|
//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().setDownloadClickListener(singleDownloadClickListener);
|
||||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||||
|
|
||||||
|
if (eventListener != null) {
|
||||||
|
eventListener.onRegisterVoiceNoteCallbacks(audioViewStub.get().getPlaybackStateObserver());
|
||||||
|
}
|
||||||
|
|
||||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, 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) { }
|
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() {
|
private void handleMessageApproval() {
|
||||||
final int title;
|
final int title;
|
||||||
final int message;
|
final int message;
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
package org.thoughtcrime.securesms.mediaoverview;
|
package org.thoughtcrime.securesms.mediaoverview;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
@ -63,6 +64,7 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter {
|
||||||
private final GlideRequests glideRequests;
|
private final GlideRequests glideRequests;
|
||||||
private final ItemClickListener itemClickListener;
|
private final ItemClickListener itemClickListener;
|
||||||
private final Map<AttachmentId, MediaRecord> selected = new HashMap<>();
|
private final Map<AttachmentId, MediaRecord> selected = new HashMap<>();
|
||||||
|
private final AudioView.Callbacks audioViewCallbacks;
|
||||||
|
|
||||||
private GroupedThreadMedia media;
|
private GroupedThreadMedia media;
|
||||||
private boolean showFileSizes;
|
private boolean showFileSizes;
|
||||||
|
@ -73,12 +75,6 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter {
|
||||||
private static final int GALLERY_DETAIL = 3;
|
private static final int GALLERY_DETAIL = 3;
|
||||||
private static final int DOCUMENT_DETAIL = 4;
|
private static final int DOCUMENT_DETAIL = 4;
|
||||||
|
|
||||||
void pause(RecyclerView.ViewHolder holder) {
|
|
||||||
if (holder instanceof AudioDetailViewHolder) {
|
|
||||||
((AudioDetailViewHolder) holder).pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void detach(RecyclerView.ViewHolder holder) {
|
void detach(RecyclerView.ViewHolder holder) {
|
||||||
if (holder instanceof SelectableViewHolder) {
|
if (holder instanceof SelectableViewHolder) {
|
||||||
((SelectableViewHolder) holder).onDetached();
|
((SelectableViewHolder) holder).onDetached();
|
||||||
|
@ -98,6 +94,7 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter {
|
||||||
@NonNull GlideRequests glideRequests,
|
@NonNull GlideRequests glideRequests,
|
||||||
GroupedThreadMedia media,
|
GroupedThreadMedia media,
|
||||||
ItemClickListener clickListener,
|
ItemClickListener clickListener,
|
||||||
|
@NonNull AudioView.Callbacks audioViewCallbacks,
|
||||||
boolean showFileSizes,
|
boolean showFileSizes,
|
||||||
boolean showThread)
|
boolean showThread)
|
||||||
{
|
{
|
||||||
|
@ -105,6 +102,7 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter {
|
||||||
this.glideRequests = glideRequests;
|
this.glideRequests = glideRequests;
|
||||||
this.media = media;
|
this.media = media;
|
||||||
this.itemClickListener = clickListener;
|
this.itemClickListener = clickListener;
|
||||||
|
this.audioViewCallbacks = audioViewCallbacks;
|
||||||
this.showFileSizes = showFileSizes;
|
this.showFileSizes = showFileSizes;
|
||||||
this.showThread = showThread;
|
this.showThread = showThread;
|
||||||
}
|
}
|
||||||
|
@ -437,25 +435,15 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter {
|
||||||
throw new AssertionError();
|
throw new AssertionError();
|
||||||
}
|
}
|
||||||
|
|
||||||
audioView.setAudio((AudioSlide) slide, true);
|
audioView.setAudio((AudioSlide) slide, audioViewCallbacks, true);
|
||||||
audioView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
audioView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
||||||
itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
itemView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
void unbind() {
|
|
||||||
audioView.stopPlaybackAndReset();
|
|
||||||
super.unbind();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getFileTypeDescription(@NonNull Context context, @NonNull Slide slide) {
|
protected String getFileTypeDescription(@NonNull Context context, @NonNull Slide slide) {
|
||||||
return context.getString(R.string.MediaOverviewActivity_audio);
|
return context.getString(R.string.MediaOverviewActivity_audio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void pause() {
|
|
||||||
audioView.stopPlaybackAndReset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class GalleryDetailViewHolder extends DetailViewHolder {
|
private class GalleryDetailViewHolder extends DetailViewHolder {
|
||||||
|
|
|
@ -17,6 +17,7 @@ import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.view.ActionMode;
|
import androidx.appcompat.view.ActionMode;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
|
@ -30,6 +31,8 @@ import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
|
||||||
import org.thoughtcrime.securesms.MediaPreviewActivity;
|
import org.thoughtcrime.securesms.MediaPreviewActivity;
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
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.MediaDatabase;
|
||||||
import org.thoughtcrime.securesms.database.loaders.GroupedThreadMediaLoader;
|
import org.thoughtcrime.securesms.database.loaders.GroupedThreadMediaLoader;
|
||||||
import org.thoughtcrime.securesms.database.loaders.MediaLoader;
|
import org.thoughtcrime.securesms.database.loaders.MediaLoader;
|
||||||
|
@ -41,6 +44,7 @@ import org.thoughtcrime.securesms.util.Util;
|
||||||
|
|
||||||
public final class MediaOverviewPageFragment extends Fragment
|
public final class MediaOverviewPageFragment extends Fragment
|
||||||
implements MediaGalleryAllAdapter.ItemClickListener,
|
implements MediaGalleryAllAdapter.ItemClickListener,
|
||||||
|
AudioView.Callbacks,
|
||||||
LoaderManager.LoaderCallbacks<GroupedThreadMediaLoader.GroupedThreadMedia>
|
LoaderManager.LoaderCallbacks<GroupedThreadMediaLoader.GroupedThreadMedia>
|
||||||
{
|
{
|
||||||
|
|
||||||
|
@ -61,6 +65,7 @@ public final class MediaOverviewPageFragment extends Fragment
|
||||||
private boolean detail;
|
private boolean detail;
|
||||||
private MediaGalleryAllAdapter adapter;
|
private MediaGalleryAllAdapter adapter;
|
||||||
private GridMode gridMode;
|
private GridMode gridMode;
|
||||||
|
private VoiceNoteMediaController voiceNoteMediaController;
|
||||||
|
|
||||||
public static @NonNull Fragment newInstance(long threadId,
|
public static @NonNull Fragment newInstance(long threadId,
|
||||||
@NonNull MediaLoader.MediaType mediaType,
|
@NonNull MediaLoader.MediaType mediaType,
|
||||||
|
@ -91,6 +96,13 @@ public final class MediaOverviewPageFragment extends Fragment
|
||||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
LoaderManager.getInstance(this).initLoader(0, null, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onActivityCreated(savedInstanceState);
|
||||||
|
|
||||||
|
voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||||
Context context = requireContext();
|
Context context = requireContext();
|
||||||
|
@ -104,6 +116,7 @@ public final class MediaOverviewPageFragment extends Fragment
|
||||||
GlideApp.with(this),
|
GlideApp.with(this),
|
||||||
new GroupedThreadMediaLoader.EmptyGroupedThreadMedia(),
|
new GroupedThreadMediaLoader.EmptyGroupedThreadMedia(),
|
||||||
this,
|
this,
|
||||||
|
this,
|
||||||
sorting.isRelatedToFileSize(),
|
sorting.isRelatedToFileSize(),
|
||||||
threadId == MediaDatabase.ALL_THREADS);
|
threadId == MediaDatabase.ALL_THREADS);
|
||||||
this.recyclerView.setAdapter(adapter);
|
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
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
|
@ -305,6 +309,26 @@ public final class MediaOverviewPageFragment extends Fragment
|
||||||
((MediaOverviewActivity) activity).onEnterMultiSelect();
|
((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 class ActionModeCallback implements ActionMode.Callback {
|
||||||
|
|
||||||
private int originalStatusBarColor;
|
private int originalStatusBarColor;
|
||||||
|
|
|
@ -142,8 +142,6 @@ public class AttachmentManager {
|
||||||
|
|
||||||
markGarbage(getSlideUri());
|
markGarbage(getSlideUri());
|
||||||
slide = Optional.absent();
|
slide = Optional.absent();
|
||||||
|
|
||||||
audioView.cleanup();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -279,7 +277,7 @@ public class AttachmentManager {
|
||||||
attachmentViewStub.get().setVisibility(View.VISIBLE);
|
attachmentViewStub.get().setVisibility(View.VISIBLE);
|
||||||
|
|
||||||
if (slide.hasAudio()) {
|
if (slide.hasAudio()) {
|
||||||
audioView.setAudio((AudioSlide) slide, false);
|
audioView.setAudio((AudioSlide) slide, null, false);
|
||||||
removableMediaView.display(audioView, false);
|
removableMediaView.display(audioView, false);
|
||||||
result.set(true);
|
result.set(true);
|
||||||
} else if (slide.hasDocument()) {
|
} else if (slide.hasDocument()) {
|
||||||
|
|
|
@ -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<Drawable> getSelfAvatarOrFallbackIcon(@NonNull Context context, @DrawableRes int fallbackIcon) {
|
public static GlideRequest<Drawable> getSelfAvatarOrFallbackIcon(@NonNull Context context, @DrawableRes int fallbackIcon) {
|
||||||
return GlideApp.with(context)
|
return GlideApp.with(context)
|
||||||
.asDrawable()
|
.asDrawable()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package org.thoughtcrime.securesms.util;
|
package org.thoughtcrime.securesms.util;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.res.Configuration;
|
||||||
import android.content.res.Resources;
|
import android.content.res.Resources;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
import androidx.annotation.AttrRes;
|
import androidx.annotation.AttrRes;
|
||||||
|
@ -21,6 +22,10 @@ import org.thoughtcrime.securesms.R;
|
||||||
|
|
||||||
public class ThemeUtil {
|
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) {
|
public static boolean isDarkTheme(@NonNull Context context) {
|
||||||
return getAttribute(context, R.attr.theme_type, "light").equals("dark");
|
return getAttribute(context, R.attr.theme_type, "light").equals("dark");
|
||||||
}
|
}
|
||||||
|
|
|
@ -2691,6 +2691,7 @@
|
||||||
<string name="GroupLinkBottomSheet_share">Share</string>
|
<string name="GroupLinkBottomSheet_share">Share</string>
|
||||||
<string name="GroupLinkBottomSheet_copied_to_clipboard">Copied to clipboard</string>
|
<string name="GroupLinkBottomSheet_copied_to_clipboard">Copied to clipboard</string>
|
||||||
<string name="GroupLinkBottomSheet_the_link_is_not_currently_active">The link is not currently active</string>
|
<string name="GroupLinkBottomSheet_the_link_is_not_currently_active">The link is not currently active</string>
|
||||||
|
<string name="VoiceNotePlaybackPreparer__could_not_start_playback">Could not start playback.</string>
|
||||||
|
|
||||||
<!-- EOF -->
|
<!-- EOF -->
|
||||||
|
|
||||||
|
|
|
@ -255,6 +255,9 @@ dependencyVerification {
|
||||||
['com.google.android.exoplayer:exoplayer-ui:2.9.1',
|
['com.google.android.exoplayer:exoplayer-ui:2.9.1',
|
||||||
'7a942afcc402ff01e9bf48e8d3942850986710f06562d50a1408aaf04a683151'],
|
'7a942afcc402ff01e9bf48e8d3942850986710f06562d50a1408aaf04a683151'],
|
||||||
|
|
||||||
|
['com.google.android.exoplayer:extension-mediasession:2.9.1',
|
||||||
|
'a7dff433b41b714c6584d925f1438ca890eaa16d66a47c3fb9ce88b39dcf3d52'],
|
||||||
|
|
||||||
['com.google.android.gms:play-services-auth-api-phone:16.0.0',
|
['com.google.android.gms:play-services-auth-api-phone:16.0.0',
|
||||||
'19365818b9ceb048ef48db12b5ffadd5eb86dbeb2c7c7b823bfdd89c665f42e5'],
|
'19365818b9ceb048ef48db12b5ffadd5eb86dbeb2c7c7b823bfdd89c665f42e5'],
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue