diff --git a/app/src/main/assets/sounds/state-change_confirm-down.ogg b/app/src/main/assets/sounds/state-change_confirm-down.ogg new file mode 100755 index 0000000000..b2b5d58fc1 Binary files /dev/null and b/app/src/main/assets/sounds/state-change_confirm-down.ogg differ diff --git a/app/src/main/assets/sounds/state-change_confirm-up.ogg b/app/src/main/assets/sounds/state-change_confirm-up.ogg new file mode 100755 index 0000000000..36dd7e99bb Binary files /dev/null and b/app/src/main/assets/sounds/state-change_confirm-up.ogg differ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java index 2616f86e12..02d021cbe5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -190,16 +190,16 @@ public final class AudioView extends FrameLayout { } private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) { - onStart(voiceNotePlaybackState.getUri()); + onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isAutoReset()); onProgress(voiceNotePlaybackState.getUri(), (double) voiceNotePlaybackState.getPlayheadPositionMillis() / durationMillis, voiceNotePlaybackState.getPlayheadPositionMillis()); } - private void onStart(@NonNull Uri uri) { + private void onStart(@NonNull Uri uri, boolean autoReset) { if (!Objects.equals(uri, audioSlide.getUri())) { if (audioSlide != null && audioSlide.getUri() != null) { - onStop(audioSlide.getUri()); + onStop(audioSlide.getUri(), autoReset); } return; @@ -213,7 +213,7 @@ public final class AudioView extends FrameLayout { togglePlayToPause(); } - private void onStop(@NonNull Uri uri) { + private void onStop(@NonNull Uri uri, boolean autoReset) { if (!Objects.equals(uri, audioSlide.getUri())) { return; } @@ -225,7 +225,7 @@ public final class AudioView extends FrameLayout { isPlaying = false; togglePauseToPlay(); - if (autoRewind || seekBar.getProgress() + 5 >= seekBar.getMax()) { + if (autoReset || autoRewind || seekBar.getProgress() + 5 >= seekBar.getMax()) { backwardsCounter = 4; rewind(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java index 38c7447ecc..1c61caba98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java @@ -22,6 +22,7 @@ import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.Util; import java.util.Objects; @@ -209,8 +210,13 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { mediaMetadataCompat != null && mediaMetadataCompat.getDescription() != null) { - voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri()), - mediaController.getPlaybackState().getPosition())); + + Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri()); + boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI); + + voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, + mediaController.getPlaybackState().getPosition(), + autoReset)); sendEmptyMessageDelayed(0, 50); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java index d6781ad1a2..bacc6fd596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java @@ -10,21 +10,28 @@ 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.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import java.util.Locale; +import java.util.Objects; + /** * 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"; + public static final String EXTRA_MESSAGE_POSITION = "voice.note.extra.MESSAGE_POSITION"; + public static final String EXTRA_THREAD_RECIPIENT_ID = "voice.note.extra.RECIPIENT_ID"; + public static final String EXTRA_AVATAR_RECIPIENT_ID = "voice.note.extra.SENDER_ID"; + public static final String EXTRA_THREAD_ID = "voice.note.extra.THREAD_ID"; + public static final String EXTRA_COLOR = "voice.note.extra.COLOR"; + public static final String EXTRA_MESSAGE_ID = "voice.note.extra.MESSAGE_ID"; private static final String TAG = Log.tag(VoiceNoteMediaDescriptionCompatFactory.class); @@ -34,48 +41,58 @@ class 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. + * @param context Context. + * @param messageRecord The MessageRecord 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) + @NonNull MessageRecord messageRecord) { - 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()); + Recipient threadRecipient = Objects.requireNonNull(DatabaseFactory.getThreadDatabase(context) + .getRecipientForThreadId(messageRecord.getThreadId())); + Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient(); + Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender; + Bundle extras = new Bundle(); - extras.putString(EXTRA_RECIPIENT_ID, messageRecord.getIndividualRecipient().getId().serialize()); + extras.putString(EXTRA_THREAD_RECIPIENT_ID, threadRecipient.getId().serialize()); + extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize()); extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition); extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId()); - extras.putString(EXTRA_COLOR, messageRecord.getIndividualRecipient().getColor().serialize()); + extras.putString(EXTRA_COLOR, threadRecipient.getColor().serialize()); + extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId()); NotificationPrivacyPreference preference = TextSecurePreferences.getNotificationPrivacy(context); String title; - if (preference.isDisplayContact()) { - title = messageRecord.getIndividualRecipient().getDisplayName(context); + if (preference.isDisplayContact() && threadRecipient.isGroup()) { + title = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s, + sender.getDisplayName(context), + threadRecipient.getDisplayName(context)); + } else if (preference.isDisplayContact()) { + title = sender.getDisplayName(context); } else { title = context.getString(R.string.MessageNotifier_signal_message); } + String subtitle = null; + if (preference.isDisplayContact()) { + subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message, + DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), + messageRecord.getDateReceived())); + } + + Uri uri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri(); + return new MediaDescriptionCompat.Builder() .setMediaUri(uri) .setTitle(title) - .setSubtitle(context.getString(R.string.ThreadRecord_voice_message)) + .setSubtitle(subtitle) .setExtras(extras) .build(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java index 681ae0ae62..11d3239171 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaSourceFactory.java @@ -1,8 +1,10 @@ package org.thoughtcrime.securesms.components.voice; import android.content.Context; +import android.net.Uri; import android.support.v4.media.MediaDescriptionCompat; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; @@ -10,6 +12,7 @@ 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.AssetDataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; @@ -17,7 +20,7 @@ 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 { +final class VoiceNoteMediaSourceFactory { private final Context context; @@ -32,7 +35,6 @@ final class VoiceNoteMediaSourceFactory implements TimelineQueueEditor.MediaSour * * @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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationControlDispatcher.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationControlDispatcher.java new file mode 100644 index 0000000000..8301a98ca2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationControlDispatcher.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.components.voice; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.Player; + +public class VoiceNoteNotificationControlDispatcher extends DefaultControlDispatcher { + + private final VoiceNoteQueueDataAdapter dataAdapter; + + public VoiceNoteNotificationControlDispatcher(@NonNull VoiceNoteQueueDataAdapter dataAdapter) { + this.dataAdapter = dataAdapter; + } + + @Override + public boolean dispatchSeekTo(Player player, int windowIndex, long positionMs) { + boolean isQueueToneIndex = windowIndex % 2 == 1; + boolean isSeekingToStart = positionMs == C.TIME_UNSET; + + if (isQueueToneIndex && isSeekingToStart) { + int nextVoiceNoteWindowIndex = player.getCurrentWindowIndex() < windowIndex ? windowIndex + 1 : windowIndex - 1; + + if (dataAdapter.size() <= nextVoiceNoteWindowIndex) { + return super.dispatchSeekTo(player, windowIndex, positionMs); + } else { + return super.dispatchSeekTo(player, nextVoiceNoteWindowIndex, positionMs); + } + } else { + return super.dispatchSeekTo(player, windowIndex, positionMs); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java index 5e05c02d13..9706e5c492 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteNotificationManager.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.voice; import android.app.PendingIntent; import android.content.Context; +import android.content.Intent; import android.graphics.Bitmap; import android.os.RemoteException; import android.support.v4.media.session.MediaControllerCompat; @@ -37,7 +38,8 @@ class VoiceNoteNotificationManager { VoiceNoteNotificationManager(@NonNull Context context, @NonNull MediaSessionCompat.Token token, - @NonNull PlayerNotificationManager.NotificationListener listener) + @NonNull PlayerNotificationManager.NotificationListener listener, + @NonNull VoiceNoteQueueDataAdapter dataAdapter) { this.context = context; @@ -54,11 +56,12 @@ class VoiceNoteNotificationManager { new DescriptionAdapter()); notificationManager.setMediaSessionToken(token); - notificationManager.setSmallIcon(R.drawable.ic_signal_grey_24dp); + notificationManager.setSmallIcon(R.drawable.ic_notification); notificationManager.setRewindIncrementMs(0); notificationManager.setFastForwardIncrementMs(0); notificationManager.setNotificationListener(listener); notificationManager.setColorized(true); + notificationManager.setControlDispatcher(new VoiceNoteNotificationControlDispatcher(dataAdapter)); } public void hideNotification() { @@ -87,7 +90,7 @@ class VoiceNoteNotificationManager { public @Nullable PendingIntent createCurrentContentIntent(Player player) { if (!hasMetadata()) return null; - RecipientId recipientId = RecipientId.from(Objects.requireNonNull(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_RECIPIENT_ID))); + RecipientId recipientId = RecipientId.from(Objects.requireNonNull(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID))); int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION); long threadId = controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID); @@ -100,20 +103,24 @@ class VoiceNoteNotificationManager { notificationManager.setColor(color.toNotificationColor(context)); + Intent conversationActivity = ConversationActivity.buildIntent(context, + recipientId, + threadId, + ThreadDatabase.DistributionTypes.DEFAULT, + startingPosition); + + conversationActivity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return PendingIntent.getActivity(context, 0, - ConversationActivity.buildIntent(context, - recipientId, - threadId, - ThreadDatabase.DistributionTypes.DEFAULT, - startingPosition), - 0); + conversationActivity, + PendingIntent.FLAG_CANCEL_CURRENT); } @Override public String getCurrentContentText(Player player) { if (hasMetadata()) { - return Objects.requireNonNull(controller.getMetadata().getDescription().getSubtitle()).toString(); + return Objects.toString(controller.getMetadata().getDescription().getSubtitle(), null); } else { return null; } @@ -127,7 +134,7 @@ class VoiceNoteNotificationManager { return null; } - RecipientId currentRecipientId = RecipientId.from(Objects.requireNonNull(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_RECIPIENT_ID))); + RecipientId currentRecipientId = RecipientId.from(Objects.requireNonNull(controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_AVATAR_RECIPIENT_ID))); if (Objects.equals(currentRecipientId, cachedRecipientId) && cachedBitmap != null) { return cachedBitmap; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java index b0dea17b1e..bb356c3f40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java @@ -4,20 +4,31 @@ import android.content.Context; import android.net.Uri; import android.os.Bundle; import android.os.ResultReceiver; +import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.session.PlaybackStateCompat; -import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; +import com.annimon.stream.Stream; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; -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.util.MessageRecordUtil; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import java.util.Collections; +import java.util.List; import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -29,21 +40,30 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP private static final String TAG = Log.tag(VoiceNotePlaybackPreparer.class); private static final Executor EXECUTOR = Executors.newSingleThreadExecutor(); + private static final long LIMIT = 5; - private final Context context; - private final SimpleExoPlayer player; - private final VoiceNoteQueueDataAdapter queueDataAdapter; - private final TimelineQueueEditor.MediaSourceFactory mediaSourceFactory; + public static final Uri NEXT_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-down.ogg"); + public static final Uri END_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-up.ogg"); + + private final Context context; + private final SimpleExoPlayer player; + private final VoiceNoteQueueDataAdapter queueDataAdapter; + private final VoiceNoteMediaSourceFactory mediaSourceFactory; + private final ConcatenatingMediaSource dataSource; + + private boolean canLoadMore; + private Uri latestUri = Uri.EMPTY; VoiceNotePlaybackPreparer(@NonNull Context context, @NonNull SimpleExoPlayer player, @NonNull VoiceNoteQueueDataAdapter queueDataAdapter, - @NonNull TimelineQueueEditor.MediaSourceFactory mediaSourceFactory) + @NonNull VoiceNoteMediaSourceFactory mediaSourceFactory) { this.context = context; this.player = player; this.queueDataAdapter = queueDataAdapter; this.mediaSourceFactory = mediaSourceFactory; + this.dataSource = new ConcatenatingMediaSource(); } @Override @@ -67,25 +87,37 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP } @Override - public void onPrepareFromUri(Uri uri, Bundle extras) { + public void onPrepareFromUri(final 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; - } + canLoadMore = false; + latestUri = uri; - queueDataAdapter.add(description); - player.seekTo(position); - player.prepare(Objects.requireNonNull(mediaSourceFactory.createMediaSource(description)), - position == 0, - false); + queueDataAdapter.clear(); + dataSource.clear(); + + SimpleTask.run(EXECUTOR, + () -> loadMediaDescriptions(messageId), + descriptions -> { + if (Util.hasItems(descriptions) && Objects.equals(latestUri, uri)) { + applyDescriptionsToQueue(descriptions); + + int window = Math.max(0, queueDataAdapter.indexOf(uri)); + + player.addListener(new Player.EventListener() { + @Override + public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) { + if (timeline.getWindowCount() >= window) { + player.seekTo(window, position); + player.removeListener(this); + } + } + }); + + player.prepare(dataSource); + canLoadMore = true; + } }); } @@ -97,4 +129,117 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP @Override public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { } + + private void applyDescriptionsToQueue(@NonNull List descriptions) { + for (MediaDescriptionCompat description : descriptions) { + int holderIndex = queueDataAdapter.indexOf(description.getMediaUri()); + MediaDescriptionCompat next = createNextClone(description); + int currentIndex = player.getCurrentWindowIndex(); + + if (holderIndex != -1) { + queueDataAdapter.remove(holderIndex); + queueDataAdapter.remove(holderIndex); + queueDataAdapter.add(holderIndex, createNextClone(description)); + queueDataAdapter.add(holderIndex, description); + + if (currentIndex != holderIndex) { + dataSource.removeMediaSource(holderIndex); + dataSource.addMediaSource(holderIndex, mediaSourceFactory.createMediaSource(description)); + } + + if (currentIndex != holderIndex + 1) { + dataSource.removeMediaSource(holderIndex + 1); + dataSource.addMediaSource(holderIndex + 1, mediaSourceFactory.createMediaSource(next)); + } + } else { + int insertLocation = queueDataAdapter.indexAfter(description); + + queueDataAdapter.add(insertLocation, next); + queueDataAdapter.add(insertLocation, description); + + dataSource.addMediaSource(insertLocation, mediaSourceFactory.createMediaSource(next)); + dataSource.addMediaSource(insertLocation, mediaSourceFactory.createMediaSource(description)); + } + } + + int lastIndex = queueDataAdapter.size() - 1; + MediaDescriptionCompat last = queueDataAdapter.getMediaDescription(lastIndex); + + if (Objects.equals(last.getMediaUri(), NEXT_URI)) { + MediaDescriptionCompat end = createEndClone(last); + + queueDataAdapter.remove(lastIndex); + queueDataAdapter.add(lastIndex, end); + dataSource.removeMediaSource(lastIndex); + dataSource.addMediaSource(lastIndex, mediaSourceFactory.createMediaSource(end)); + } + } + + private @NonNull MediaDescriptionCompat createEndClone(@NonNull MediaDescriptionCompat source) { + return buildUpon(source).setMediaId("end").setMediaUri(END_URI).build(); + } + + private @NonNull MediaDescriptionCompat createNextClone(@NonNull MediaDescriptionCompat source) { + return buildUpon(source).setMediaId("next").setMediaUri(NEXT_URI).build(); + } + + private @NonNull MediaDescriptionCompat.Builder buildUpon(@NonNull MediaDescriptionCompat source) { + return new MediaDescriptionCompat.Builder() + .setSubtitle(source.getSubtitle()) + .setDescription(source.getDescription()) + .setTitle(source.getTitle()) + .setIconUri(source.getIconUri()) + .setIconBitmap(source.getIconBitmap()) + .setMediaId(source.getMediaId()) + .setExtras(source.getExtras()); + } + + public void loadMoreVoiceNotes() { + if (!canLoadMore) { + return; + } + + MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(player.getCurrentWindowIndex()); + long messageId = mediaDescriptionCompat.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID); + + SimpleTask.run(EXECUTOR, + () -> loadMediaDescriptions(messageId), + descriptions -> { + if (Util.hasItems(descriptions) && canLoadMore) { + applyDescriptionsToQueue(descriptions); + } + }); + } + + @WorkerThread + private @NonNull List loadMediaDescriptions(long messageId) { + try { + List recordsBefore = DatabaseFactory.getMmsSmsDatabase(context).getMessagesBeforeVoiceNoteExclusive(messageId, LIMIT); + List recordsAfter = DatabaseFactory.getMmsSmsDatabase(context).getMessagesAfterVoiceNoteInclusive(messageId, LIMIT); + + return Stream.of(buildFilteredMessageRecordList(recordsBefore, recordsAfter)) + .map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record)) + .toList(); + } catch (NoSuchMessageException e) { + Log.w(TAG, "Could not find message.", e); + return Collections.emptyList(); + } + } + + @VisibleForTesting + static @NonNull List buildFilteredMessageRecordList(@NonNull List recordsBefore, @NonNull List recordsAfter) { + Collections.reverse(recordsBefore); + List filteredBefore = Stream.of(recordsBefore) + .takeWhile(MessageRecordUtil::hasAudio) + .toList(); + Collections.reverse(filteredBefore); + + List filteredAfter = Stream.of(recordsAfter) + .takeWhile(MessageRecordUtil::hasAudio) + .toList(); + + filteredBefore.addAll(filteredAfter); + + return filteredBefore; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java index 52a15fff9c..895e0288c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -55,13 +55,14 @@ import java.util.Objects; */ 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 String TAG = Log.tag(VoiceNotePlaybackService.class); + private static final String EMPTY_ROOT_ID = "empty-root-id"; + private static final int LOAD_MORE_THRESHOLD = 2; - private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY | - PlaybackStateCompat.ACTION_PAUSE | - PlaybackStateCompat.ACTION_SEEK_TO | - PlaybackStateCompat.ACTION_STOP | + 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; @@ -71,6 +72,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { private BecomingNoisyReceiver becomingNoisyReceiver; private VoiceNoteNotificationManager voiceNoteNotificationManager; private VoiceNoteQueueDataAdapter queueDataAdapter; + private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer; private boolean isForegroundService; private final LoadControl loadControl = new DefaultLoadControl.Builder() @@ -93,19 +95,22 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { queueDataAdapter = new VoiceNoteQueueDataAdapter(); voiceNoteNotificationManager = new VoiceNoteNotificationManager(this, mediaSession.getSessionToken(), - new VoiceNoteNotificationManagerListener()); + new VoiceNoteNotificationManagerListener(), + queueDataAdapter); VoiceNoteMediaSourceFactory mediaSourceFactory = new VoiceNoteMediaSourceFactory(this); + voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory); + mediaSession.setPlaybackState(stateBuilder.build()); player.addListener(new VoiceNotePlayerEventListener()); player.setAudioAttributes(new AudioAttributes.Builder() .setContentType(C.CONTENT_TYPE_SPEECH) .setUsage(C.USAGE_MEDIA) - .build()); + .build(), true); - mediaSessionConnector.setPlayer(player, new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory)); + mediaSessionConnector.setPlayer(player, voiceNotePlaybackPreparer); mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession, queueDataAdapter)); setSessionToken(mediaSession.getSessionToken()); @@ -163,6 +168,17 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { voiceNoteNotificationManager.hideNotification(); } } + + @Override + public void onPositionDiscontinuity(int reason) { + int currentWindowIndex = player.getCurrentWindowIndex(); + boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD || + currentWindowIndex + LOAD_MORE_THRESHOLD >= queueDataAdapter.size(); + + if (isWithinThreshold && currentWindowIndex % 2 == 0) { + voiceNotePlaybackPreparer.loadMoreVoiceNotes(); + } + } } private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java index 75b2537930..83ce65cbc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackState.java @@ -9,14 +9,16 @@ import androidx.annotation.NonNull; */ public class VoiceNotePlaybackState { - public static final VoiceNotePlaybackState NONE = new VoiceNotePlaybackState(Uri.EMPTY, 0); + public static final VoiceNotePlaybackState NONE = new VoiceNotePlaybackState(Uri.EMPTY, 0, false); - private final Uri uri; - private final long playheadPositionMillis; + private final Uri uri; + private final long playheadPositionMillis; + private final boolean autoReset; - public VoiceNotePlaybackState(@NonNull Uri uri, long playheadPositionMillis) { + public VoiceNotePlaybackState(@NonNull Uri uri, long playheadPositionMillis, boolean autoReset) { this.uri = uri; this.playheadPositionMillis = playheadPositionMillis; + this.autoReset = autoReset; } /** @@ -32,4 +34,11 @@ public class VoiceNotePlaybackState { public long getPlayheadPositionMillis() { return playheadPositionMillis; } + + /** + * @return true if we should reset the currently playing clip. + */ + public boolean isAutoReset() { + return autoReset; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java index c0a971bdd4..85f965a56d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteQueueDataAdapter.java @@ -4,6 +4,7 @@ import android.net.Uri; import android.support.v4.media.MediaDescriptionCompat; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor; @@ -39,8 +40,8 @@ final class VoiceNoteQueueDataAdapter implements TimelineQueueEditor.QueueDataAd descriptions.add(to, description); } - void add(MediaDescriptionCompat description) { - descriptions.add(description); + int size() { + return descriptions.size(); } int indexOf(@NonNull Uri uri) { @@ -53,6 +54,27 @@ final class VoiceNoteQueueDataAdapter implements TimelineQueueEditor.QueueDataAd return -1; } + int indexAfter(@NonNull MediaDescriptionCompat target) { + if (isEmpty()) { + return 0; + } + + long targetMessageId = target.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID); + for (int i = 0; i < descriptions.size(); i++) { + long descriptionMessageId = descriptions.get(i).getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID); + + if (descriptionMessageId > targetMessageId) { + return i; + } + } + + return descriptions.size(); + } + + boolean isEmpty() { + return descriptions.isEmpty(); + } + void clear() { descriptions.clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 6e7ae2e62e..be816148bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -145,6 +145,9 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns abstract void deleteAllThreads(); abstract void deleteAbandonedMessages(); + public abstract List getMessagesInThreadBeforeExclusive(long threadId, long timestamp, long limit); + public abstract List getMessagesInThreadAfterInclusive(long threadId, long timestamp, long limit); + public abstract SQLiteDatabase beginTransaction(); public abstract void endTransaction(SQLiteDatabase database); public abstract void setTransactionSuccessful(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 0323bb9848..f4743debea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -82,6 +82,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -605,11 +606,25 @@ public class MmsDatabase extends MessageDatabase { } private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) { + return rawQuery(where, arguments, false, 0); + } + + private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); - return database.rawQuery("SELECT " + Util.join(MMS_PROJECTION, ",") + - " FROM " + MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + - " ON (" + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" + - " WHERE " + where + " GROUP BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, arguments); + String rawQueryString = "SELECT " + Util.join(MMS_PROJECTION, ",") + + " FROM " + MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + + " ON (" + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " = " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ")" + + " WHERE " + where + " GROUP BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID; + + if (reverse) { + rawQueryString += " ORDER BY " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " DESC"; + } + + if (limit > 0) { + rawQueryString += " LIMIT " + limit; + } + + return database.rawQuery(rawQueryString, arguments); } private Cursor internalGetMessage(long messageId) { @@ -1603,6 +1618,40 @@ public class MmsDatabase extends MessageDatabase { db.delete(TABLE_NAME, where, null); } + @Override + public List getMessagesInThreadBeforeExclusive(long threadId, long timestamp, long limit) { + String where = TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? AND " + + TABLE_NAME + "." + getDateReceivedColumnName() + " < ?"; + String[] args = SqlUtil.buildArgs(threadId, timestamp); + + try (Reader reader = readerFor(rawQuery(where, args, true, limit))) { + List results = new ArrayList<>(reader.cursor.getCount()); + + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + @Override + public List getMessagesInThreadAfterInclusive(long threadId, long timestamp, long limit) { + String where = TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? AND " + + TABLE_NAME + "." + getDateReceivedColumnName() + " >= ?"; + String[] args = SqlUtil.buildArgs(threadId, timestamp); + + try (Reader reader = readerFor(rawQuery(where, args, false, limit))) { + List results = new ArrayList<>(reader.cursor.getCount()); + + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + @Override public void deleteAllThreads() { DatabaseFactory.getAttachmentDatabase(context).deleteAllAttachments(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index a63a9d98f1..65641c3a36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -22,6 +22,8 @@ import android.database.Cursor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.annimon.stream.Stream; + import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteQueryBuilder; @@ -33,7 +35,9 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.util.Pair; import java.io.Closeable; +import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; public class MmsSmsDatabase extends Database { @@ -158,6 +162,30 @@ public class MmsSmsDatabase extends Database { return null; } + + public @NonNull List getMessagesBeforeVoiceNoteExclusive(long messageId, long limit) throws NoSuchMessageException { + MessageRecord origin = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + List mms = DatabaseFactory.getMmsDatabase(context).getMessagesInThreadBeforeExclusive(origin.getThreadId(), origin.getDateReceived(), limit); + List sms = DatabaseFactory.getSmsDatabase(context).getMessagesInThreadBeforeExclusive(origin.getThreadId(), origin.getDateReceived(), limit); + + mms.addAll(sms); + Collections.sort(mms, (a, b) -> Long.compare(a.getDateReceived(), b.getDateReceived())); + + return Stream.of(mms).skip(Math.max(0, mms.size() - limit)).toList(); + } + + public @NonNull List getMessagesAfterVoiceNoteInclusive(long messageId, long limit) throws NoSuchMessageException { + MessageRecord origin = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + List mms = DatabaseFactory.getMmsDatabase(context).getMessagesInThreadAfterInclusive(origin.getThreadId(), origin.getDateReceived(), limit); + List sms = DatabaseFactory.getSmsDatabase(context).getMessagesInThreadAfterInclusive(origin.getThreadId(), origin.getDateReceived(), limit); + + mms.addAll(sms); + Collections.sort(mms, (a, b) -> Long.compare(a.getDateReceived(), b.getDateReceived())); + + return Stream.of(mms).limit(limit).toList(); + } + + public Cursor getConversation(long threadId, long offset, long limit) { String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 3f6d53862b..c8913de5e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -58,8 +58,10 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; +import java.io.Closeable; import java.io.IOException; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedList; @@ -983,6 +985,53 @@ public class SmsDatabase extends MessageDatabase { db.delete(TABLE_NAME, where, null); } + @Override + public List getMessagesInThreadBeforeExclusive(long threadId, long timestamp, long limit) { + String where = TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? AND " + + TABLE_NAME + "." + getDateReceivedColumnName() + " < ?"; + String[] args = SqlUtil.buildArgs(threadId, timestamp); + + try (Reader reader = readerFor(queryMessages(where, args, true, limit))) { + List results = new ArrayList<>(reader.cursor.getCount()); + + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + @Override + public List getMessagesInThreadAfterInclusive(long threadId, long timestamp, long limit) { + String where = TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? AND " + + TABLE_NAME + "." + getDateReceivedColumnName() + " >= ?"; + String[] args = SqlUtil.buildArgs(threadId, timestamp); + + try (Reader reader = readerFor(queryMessages(where, args, false, limit))) { + List results = new ArrayList<>(reader.cursor.getCount()); + + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + private Cursor queryMessages(@NonNull String where, @NonNull String[] args, boolean reverse, long limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + return db.query(TABLE_NAME, + MESSAGE_PROJECTION, + where, + args, + null, + null, + reverse ? ID + " DESC" : null, + limit > 0 ? String.valueOf(limit) : null); + } + @Override void deleteThreads(@NonNull Set threadIds) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); @@ -1185,7 +1234,7 @@ public class SmsDatabase extends MessageDatabase { } } - public static class Reader { + public static class Reader implements Closeable { private final Cursor cursor; private final Context context; @@ -1256,6 +1305,7 @@ public class SmsDatabase extends MessageDatabase { return new LinkedList<>(); } + @Override public void close() { cursor.close(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java index d98ee43d1c..af5ad3195c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java @@ -46,7 +46,7 @@ public final class GenericForegroundService extends Service { private final LinkedHashMap allActiveMessages = new LinkedHashMap<>(); - private static final Entry DEFAULTS = new Entry("", NotificationChannels.OTHER, R.drawable.ic_signal_grey_24dp, -1, 0, 0, false); + private static final Entry DEFAULTS = new Entry("", NotificationChannels.OTHER, R.drawable.ic_notification, -1, 0, 0, false); private @Nullable Entry lastPosted; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.java index c949ec04fa..955ebbbf44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.java @@ -33,4 +33,8 @@ public final class MessageRecordUtil { return messageRecord.isMms() && Stream.of(((MmsMessageRecord) messageRecord).getSlideDeck().getSlides()) .anyMatch(Slide::hasLocation); } + + public static boolean hasAudio(MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null; + } } diff --git a/app/src/main/res/drawable-hdpi/ic_signal_grey_24dp.webp b/app/src/main/res/drawable-hdpi/ic_signal_grey_24dp.webp deleted file mode 100644 index cf50f37aac..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_signal_grey_24dp.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_signal_grey_24dp.webp b/app/src/main/res/drawable-mdpi/ic_signal_grey_24dp.webp deleted file mode 100644 index e13ec7c900..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_signal_grey_24dp.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_signal_grey_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_signal_grey_24dp.webp deleted file mode 100644 index 026998299e..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_signal_grey_24dp.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_signal_grey_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_signal_grey_24dp.webp deleted file mode 100644 index 3c1a83cf2f..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_signal_grey_24dp.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_signal_grey_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_signal_grey_24dp.webp deleted file mode 100644 index 9d9d3c88f0..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_signal_grey_24dp.webp and /dev/null differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43e21f6fae..5ce70d0d78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2701,8 +2701,14 @@ Share Copied to clipboard The link is not currently active + + Could not start playback. + + Voice message · %1$s + %1$s to %2$s +