From a9832c9c5386bdca41879941f630f8f7c0613bf9 Mon Sep 17 00:00:00 2001 From: wch423 Date: Wed, 21 Jan 2026 18:47:56 +0100 Subject: [PATCH 01/19] Make audio view use Media3 for playback --- build.gradle | 6 +- src/main/AndroidManifest.xml | 10 + .../securesms/ConversationItem.java | 21 -- .../securesms/audio/AudioSlidePlayer.java | 1 + .../securesms/components/AudioView.java | 285 ++++++++++-------- .../securesms/mms/AttachmentManager.java | 4 +- .../service/AudioPlaybackService.java | 53 ++++ 7 files changed, 232 insertions(+), 148 deletions(-) create mode 100644 src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java diff --git a/build.gradle b/build.gradle index 44b9ff39a..54a81890b 100644 --- a/build.gradle +++ b/build.gradle @@ -152,6 +152,7 @@ android { } dependencies { + def media3_version = "1.8.0" // 1.9.0 need minSdkVersion 23 implementation 'androidx.concurrent:concurrent-futures:1.3.0' implementation 'androidx.sharetarget:sharetarget:1.2.0' implementation 'androidx.webkit:webkit:1.14.0' @@ -172,8 +173,11 @@ dependencies { implementation 'androidx.work:work-runtime:2.9.1' implementation 'androidx.emoji2:emoji2-emojipicker:1.5.0' implementation 'com.google.guava:guava:31.1-android' - implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // plays video and audio + implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // TODO: I am keeping the exoplayer dependencies for Video, but we shall migrate them at some point implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' + implementation "androidx.media3:media3-exoplayer:$media3_version" + implementation "androidx.media3:media3-session:$media3_version" + implementation "androidx.media3:media3-ui:$media3_version" implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'com.google.zxing:core:3.3.0' // fixed version to support SDK<24 implementation ('com.journeyapps:zxing-android-embedded:4.3.0') { transitive = false } // QR Code scanner diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 70645bbf7..0ad6ce5e2 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ + @@ -391,6 +392,15 @@ android:name=".service.FetchForegroundService" android:foregroundServiceType="dataSync" /> + + + + + + diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index d388cc226..ab0c9ab0a 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -480,22 +480,6 @@ else if (messageRecord.hasHtml()) { private void setMediaAttributes(@NonNull DcMsg messageRecord, boolean showSender) { - class SetDurationListener implements AudioSlidePlayer.Listener { - @Override - public void onStart() {} - - @Override - public void onStop() {} - - @Override - public void onProgress(AudioSlide slide, double progress, long millis) {} - - @Override - public void onReceivedDuration(int millis) { - messageRecord.lateFilingMediaSize(0,0, millis); - audioViewStub.get().setDuration(millis); - } - } if (hasAudio(messageRecord)) { audioViewStub.get().setVisibility(View.VISIBLE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); @@ -507,11 +491,6 @@ public void onReceivedDuration(int millis) { //noinspection ConstantConditions int duration = messageRecord.getDuration(); - if (duration == 0) { - AudioSlide audio = new AudioSlide(context, messageRecord); - AudioSlidePlayer audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, new SetDurationListener()); - audioSlidePlayer.requestDuration(); - } audioViewStub.get().setAudio(new AudioSlide(context, messageRecord), duration); audioViewStub.get().setOnClickListener(passthroughClickListener); diff --git a/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java index 20cae692d..fe461f949 100644 --- a/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ b/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -38,6 +38,7 @@ import java.io.IOException; import java.lang.ref.WeakReference; +@Deprecated public class AudioSlidePlayer { private static final String TAG = AudioSlidePlayer.class.getSimpleName(); diff --git a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/AudioView.java index ee73d0658..91aab6894 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -1,14 +1,15 @@ package org.thoughtcrime.securesms.components; +import android.content.ComponentName; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.drawable.AnimatedVectorDrawable; -import android.media.AudioAttributes; -import android.media.AudioFocusRequest; import android.media.AudioManager; -import android.os.Build; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; import android.util.AttributeSet; import android.util.Log; import android.view.View; @@ -19,16 +20,21 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; +import androidx.media3.session.MediaController; +import androidx.media3.session.SessionToken; + +import com.google.common.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.audio.AudioSlidePlayer; import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.DateUtils; -import java.io.IOException; - -public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener { +public class AudioView extends FrameLayout { private static final String TAG = AudioView.class.getSimpleName(); @@ -40,9 +46,14 @@ public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener private final @NonNull TextView title; private final @NonNull View mask; - private @Nullable AudioSlidePlayer audioSlidePlayer; + private @Nullable MediaController mediaController; + private Handler progressHandler; + private Runnable progressUpdater; + private boolean isUserSeeking = false; private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; private int backwardsCounter; + private Uri audioUri; + private ListenableFuture mediaControllerFuture; public AudioView(Context context) { this(context, null); @@ -66,24 +77,130 @@ public AudioView(Context context, AttributeSet attrs, int defStyleAttr) { this.timestamp.setText("00:00"); - this.playButton.setOnClickListener(new PlayClickedListener()); - this.pauseButton.setOnClickListener(new PauseClickedListener()); - this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener()); - this.playButton.setImageDrawable(context.getDrawable(R.drawable.play_icon)); this.pauseButton.setImageDrawable(context.getDrawable(R.drawable.pause_icon)); this.playButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp)); this.pauseButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp)); + progressHandler = new Handler(Looper.getMainLooper()); + setTint(getContext().getResources().getColor(R.color.audio_icon)); } + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + initializeController(); + } + + private void initializeController() { + Context context = getContext(); + SessionToken sessionToken = new SessionToken(context, + new ComponentName(context, AudioPlaybackService.class)); + mediaControllerFuture = new MediaController.Builder(context, sessionToken) + .buildAsync(); + mediaControllerFuture.addListener(() -> { + try { + mediaController = mediaControllerFuture.get(); + setupControls(); + updateUIFromController(); + } catch (Exception e) { + Log.e(TAG, "Error connecting to audio playback service", e); + } + }, ContextCompat.getMainExecutor(context)); + } + + private void updateUIFromController() { + // TODO + // Or better just use a ViewModel + } + + private void setupControls() { + if (mediaController == null) return; + if (audioUri == null) return; + + mediaController.addListener(new Player.Listener() { + @Override + public void onEvents(Player player, Player.Events events) { + if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) { + if (player.isPlaying()) { + if (pauseButton.getVisibility() != View.VISIBLE) { + togglePlayToPause(); + } + startUpdateProgress(); + } else if (!player.isPlaying()) { + if (playButton.getVisibility() != View.VISIBLE) { + togglePauseToPlay(); + } + stopUpdateProgress(); + } + } + } + }); + + playButton.setOnClickListener(v -> { + Log.w(TAG, "playButton onClick"); + MediaItem currentItem = mediaController.getCurrentMediaItem(); + if (currentItem == null || + (currentItem.localConfiguration != null && !audioUri.equals(currentItem.localConfiguration.uri))) { + // Different media + MediaItem mediaItem = MediaItem.fromUri(audioUri); + mediaController.setMediaItem(mediaItem); + mediaController.prepare(); + mediaController.play(); + togglePlayToPause(); + } else { + // Same media, just resume + if (!mediaController.isPlaying()) { + mediaController.play(); + togglePlayToPause(); + } + } + }); + pauseButton.setOnClickListener(v -> { + Log.w(TAG, "pauseButton onClick"); + if (mediaController.isPlaying()) { + togglePauseToPlay(); + mediaController.pause(); + } + }); + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + timestamp.setText(DateUtils.getFormatedDuration(progress)); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + isUserSeeking = true; + stopUpdateProgress(); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + isUserSeeking = false; + if (mediaController != null) { + mediaController.seekTo(seekBar.getProgress()); + } + startUpdateProgress(); + } + }); + } + + @Override + protected void onDetachedFromWindow() { + releaseController(); + super.onDetachedFromWindow(); + } + public void setAudio(final @NonNull AudioSlide audio, int duration) { controlToggle.displayQuick(playButton); seekBar.setEnabled(true); seekBar.setProgress(0); - audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this); + audioUri = audio.getUri(); timestamp.setText(DateUtils.getFormatedDuration(duration)); if(audio.asAttachment().isVoiceNote() || !audio.getFileName().isPresent()) { @@ -131,38 +248,52 @@ public String getDescription() { return desc; } + @Deprecated public void setDuration(int duration) { if (getProgress()==0) this.timestamp.setText(DateUtils.getFormatedDuration(duration)); } - public void cleanup() { - if (this.audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { - this.audioSlidePlayer.stop(); + public void releaseController() { + if (mediaController != null && mediaControllerFuture != null) { + MediaController.releaseFuture(mediaControllerFuture); } } - @Override - public void onReceivedDuration(int millis) { - this.timestamp.setText(DateUtils.getFormatedDuration(millis)); + // Poll progress and update UI + private void startUpdateProgress() { + if (progressUpdater == null) { + progressUpdater = new Runnable() { + @Override + public void run() { + if (mediaController != null && !isUserSeeking) { + updateProgress(); + // Update every 100ms for smooth progress + progressHandler.postDelayed(this, 100); + } + } + }; + } + progressHandler.removeCallbacks(progressUpdater); + progressHandler.post(progressUpdater); } - @Override - public void onStart() { - if (this.pauseButton.getVisibility() != View.VISIBLE) { - togglePlayToPause(); + private void stopUpdateProgress() { + if (progressUpdater != null) { + progressHandler.removeCallbacks(progressUpdater); } } - @Override - public void onStop() { - if (this.playButton.getVisibility() != View.VISIBLE) { - togglePauseToPlay(); - } + private void updateProgress() { + if (mediaController == null) return; + + long currentPosition = mediaController.getCurrentPosition(); + long duration = mediaController.getDuration(); - if (seekBar.getProgress() + 5 >= seekBar.getMax()) { - backwardsCounter = 4; - onProgress(audioSlidePlayer.getAudioSlide(), 0.0, -1); + if (duration > 0) { + seekBar.setMax((int) duration); + seekBar.setProgress((int) currentPosition); + timestamp.setText(DateUtils.getFormatedDuration(currentPosition)); } } @@ -170,24 +301,6 @@ public void disablePlayer(boolean disable) { this.mask.setVisibility(disable? View.VISIBLE : View.GONE); } - @Override - public void onProgress(AudioSlide slide, double progress, long millis) { - if (!audioSlidePlayer.getAudioSlide().equals(slide)) { - return; - } - int seekProgress = (int) Math.floor(progress * this.seekBar.getMax()); - - if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) { - backwardsCounter = 0; - this.seekBar.setProgress(seekProgress); - if (millis != -1) { - this.timestamp.setText(DateUtils.getFormatedDuration(millis)); - } - } else { - backwardsCounter++; - } - } - public void setTint(int foregroundTint) { this.playButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint)); this.pauseButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint)); @@ -224,78 +337,4 @@ private void togglePauseToPlay() { playButton.setImageDrawable(pauseToPlayDrawable); pauseToPlayDrawable.start(); } - - private class PlayClickedListener implements View.OnClickListener { - @Override - public void onClick(View v) { - try { - Log.w(TAG, "playbutton onClick"); - if (audioSlidePlayer != null) { - if (Build.VERSION.SDK_INT >= 26) { - if (audioFocusChangeListener == null) { - audioFocusChangeListener = focusChange -> { - if (focusChange == AudioManager.AUDIOFOCUS_LOSS) { - pauseButton.performClick(); - } - }; - } - - AudioAttributes playbackAttributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build(); - - AudioFocusRequest focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes(playbackAttributes) - .setAcceptsDelayedFocusGain(false) - .setWillPauseWhenDucked(false) - .setOnAudioFocusChangeListener(audioFocusChangeListener) - .build(); - - AudioManager audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE); - audioManager.requestAudioFocus(focusRequest); - } - - togglePlayToPause(); - audioSlidePlayer.play(getProgress()); - } - } catch (IOException e) { - Log.w(TAG, e); - } - } - } - - private class PauseClickedListener implements View.OnClickListener { - @Override - public void onClick(View v) { - Log.w(TAG, "pausebutton onClick"); - if (audioSlidePlayer != null) { - togglePauseToPlay(); - audioSlidePlayer.stop(); - } - } - } - - private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {} - - @Override - public synchronized void onStartTrackingTouch(SeekBar seekBar) { - if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { - audioSlidePlayer.stop(); - } - } - - @Override - public synchronized void onStopTrackingTouch(SeekBar seekBar) { - try { - if (audioSlidePlayer != null && pauseButton.getVisibility() == View.VISIBLE) { - audioSlidePlayer.play(getProgress()); - } - } catch (IOException e) { - Log.w(TAG, e); - } - } - } } diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index c2902b63e..5e7242058 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -90,7 +90,7 @@ public class AttachmentManager { private RemovableEditableMediaView removableMediaView; private ThumbnailView thumbnail; - private AudioView audioView; + private AudioView audioView; // TODO: Model shall not directly manipulate View private DocumentView documentView; private WebxdcView webxdcView; private VcardView vcardView; @@ -152,8 +152,6 @@ public void onFailure(ExecutionException e) { markGarbage(getSlideUri()); slide = Optional.absent(); - - audioView.cleanup(); } } diff --git a/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java new file mode 100644 index 000000000..18adf852c --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.service; + +import android.provider.MediaStore; + +import androidx.annotation.Nullable; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.session.MediaSession; +import androidx.media3.session.MediaSessionService; + +public class AudioPlaybackService extends MediaSessionService { + + private ExoPlayer player; + private MediaSession session; + + @Override + public void onCreate() { + super.onCreate(); + + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) // USAGE_VOICE_COMMUNICATION is for VoIP calls + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .build(); + + player = new ExoPlayer.Builder(this) + .setAudioAttributes(audioAttributes, true) + .setHandleAudioBecomingNoisy(true) + .build(); + + session = new MediaSession.Builder(this, player) + .build(); + } + + @Nullable + @Override + public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) { + return session; + } + + @Override + public void onDestroy() { + if (session != null) { + session.release(); + session = null; + } + if (player != null) { + player.release(); + player = null; + } + super.onDestroy(); + } +} From 9a121b30393aa7acaea2e966b63db06e628af60b Mon Sep 17 00:00:00 2001 From: wch423 Date: Fri, 23 Jan 2026 21:38:56 +0100 Subject: [PATCH 02/19] Fix UI update problems; Make notification clickable --- .../securesms/components/AudioView.java | 46 +++++++++++++------ .../service/AudioPlaybackService.java | 13 ++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/AudioView.java index 91aab6894..5fb8099cc 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -111,8 +111,13 @@ private void initializeController() { } private void updateUIFromController() { - // TODO - // Or better just use a ViewModel + if (mediaController == null) return; + + if (mediaController.isPlaying()) { + updateUIForPlay(); + } else if (!mediaController.isPlaying()) { + updateUIForPause(); + } } private void setupControls() { @@ -124,15 +129,15 @@ private void setupControls() { public void onEvents(Player player, Player.Events events) { if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) { if (player.isPlaying()) { - if (pauseButton.getVisibility() != View.VISIBLE) { - togglePlayToPause(); - } - startUpdateProgress(); + updateUIForPlay(); } else if (!player.isPlaying()) { - if (playButton.getVisibility() != View.VISIBLE) { - togglePauseToPlay(); - } - stopUpdateProgress(); + updateUIForPause(); + } + } + if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + if (player.getPlaybackState() == Player.STATE_ENDED + && player.getAvailableCommands().contains(Player.COMMAND_PLAY_PAUSE)) { + mediaController.setPlayWhenReady(false); } } } @@ -148,20 +153,20 @@ public void onEvents(Player player, Player.Events events) { mediaController.setMediaItem(mediaItem); mediaController.prepare(); mediaController.play(); - togglePlayToPause(); + updateUIForPlay(); } else { // Same media, just resume if (!mediaController.isPlaying()) { mediaController.play(); - togglePlayToPause(); + updateUIForPlay(); } } }); pauseButton.setOnClickListener(v -> { Log.w(TAG, "pauseButton onClick"); if (mediaController.isPlaying()) { - togglePauseToPlay(); mediaController.pause(); + updateUIForPause(); } }); seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @@ -189,6 +194,20 @@ public void onStopTrackingTouch(SeekBar seekBar) { }); } + private void updateUIForPlay() { + if (pauseButton.getVisibility() != View.VISIBLE) { + togglePlayToPause(); + } + startUpdateProgress(); + } + + private void updateUIForPause() { + if (playButton.getVisibility() != View.VISIBLE) { + togglePauseToPlay(); + } + stopUpdateProgress(); + } + @Override protected void onDetachedFromWindow() { releaseController(); @@ -282,6 +301,7 @@ private void stopUpdateProgress() { if (progressUpdater != null) { progressHandler.removeCallbacks(progressUpdater); } + updateProgress(); // Make sure the UI is aligned even when update has stopped } private void updateProgress() { diff --git a/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java index 18adf852c..09122d806 100644 --- a/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java +++ b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.service; +import android.app.PendingIntent; +import android.content.Intent; import android.provider.MediaStore; import androidx.annotation.Nullable; @@ -9,6 +11,8 @@ import androidx.media3.session.MediaSession; import androidx.media3.session.MediaSessionService; +import org.thoughtcrime.securesms.ConversationListActivity; + public class AudioPlaybackService extends MediaSessionService { private ExoPlayer player; @@ -28,7 +32,16 @@ public void onCreate() { .setHandleAudioBecomingNoisy(true) .build(); + // This is for click on the notification to go back to app + // TODO: Go to the right conversation + Intent intent = new Intent(this, ConversationListActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + session = new MediaSession.Builder(this, player) + .setSessionActivity(pendingIntent) .build(); } From 4bd74324d250ecc69d6e9ee03986013d60844e56 Mon Sep 17 00:00:00 2001 From: wch423 Date: Mon, 26 Jan 2026 18:47:52 +0100 Subject: [PATCH 03/19] Try to decouple view and business logics --- .../securesms/AllMediaDocumentsAdapter.java | 2 +- .../securesms/BaseConversationItem.java | 10 +- .../securesms/BindableConversationItem.java | 4 +- .../securesms/ConversationAdapter.java | 9 +- .../securesms/ConversationFragment.java | 44 ++++- .../securesms/ConversationItem.java | 15 +- .../securesms/ConversationUpdateItem.java | 6 +- .../audioplay/AudioPlaybackState.java | 51 +++++ .../audioplay/AudioPlaybackViewModel.java | 187 ++++++++++++++++++ .../components/{ => audioplay}/AudioView.java | 47 ++++- .../securesms/mms/AttachmentManager.java | 2 +- src/main/res/layout/audio_view.xml | 2 +- ...sation_activity_attachment_editor_stub.xml | 2 +- .../conversation_item_received_audio.xml | 2 +- .../layout/conversation_item_sent_audio.xml | 2 +- src/main/res/layout/profile_document_item.xml | 2 +- 16 files changed, 360 insertions(+), 27 deletions(-) create mode 100644 src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java create mode 100644 src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java rename src/main/java/org/thoughtcrime/securesms/components/{ => audioplay}/AudioView.java (89%) diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java index c582c7288..cb05a269e 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java @@ -11,7 +11,7 @@ import com.b44t.messenger.DcMsg; import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; -import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.WebxdcView; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java index c0bfcf296..01b798e79 100644 --- a/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java @@ -48,11 +48,11 @@ public BaseConversationItem(Context context, AttributeSet attrs) { this.rpc = DcHelper.getRpc(context); } - protected void bind(@NonNull DcMsg messageRecord, - @NonNull DcChat dcChat, - @NonNull Set batchSelected, - boolean pulseHighlight, - @NonNull Recipient conversationRecipient) + protected void bindPartial(@NonNull DcMsg messageRecord, + @NonNull DcChat dcChat, + @NonNull Set batchSelected, + boolean pulseHighlight, + @NonNull Recipient conversationRecipient) { this.messageRecord = messageRecord; this.dcChat = dcChat; diff --git a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index af0740195..744ef8787 100644 --- a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -6,6 +6,7 @@ import com.b44t.messenger.DcChat; import com.b44t.messenger.DcMsg; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; @@ -17,7 +18,8 @@ void bind(@NonNull DcMsg messageRecord, @NonNull GlideRequests glideRequests, @NonNull Set batchSelected, @NonNull Recipient recipients, - boolean pulseHighlight); + boolean pulseHighlight, + @Nullable AudioPlaybackViewModel playbackViewModel); DcMsg getMessageRecord(); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java index 10d30f9b0..666de291c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java @@ -34,6 +34,7 @@ import com.b44t.messenger.DcMsg; import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; @@ -59,6 +60,7 @@ * @author Moxie Marlinspike * */ +// TODO: this breaks type checks, that is why there are so many casts. public class ConversationAdapter extends RecyclerView.Adapter implements StickyHeaderDecoration.StickyHeaderAdapter @@ -97,6 +99,7 @@ public class ConversationAdapter private long pulseHighlightingSince = -1; private int lastSeenPosition = -1; private long lastSeen = -1; + private AudioPlaybackViewModel playbackViewModel; protected static class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(final @NonNull V itemView) { @@ -170,6 +173,10 @@ public long getItemId(int position) { return fromDb; } + public void setPlaybackViewModel(AudioPlaybackViewModel playbackViewModel) { + this.playbackViewModel = playbackViewModel; + } + /** * Returns the position of the message with msgId in the chat list, counted from the top */ @@ -237,7 +244,7 @@ public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { long elapsed = now - pulseHighlightingSince; boolean pulseHighlight = (positionCurrentlyPulseHighlighting == position && elapsed < PULSE_HIGHLIGHT_MILLIS); - holder.getItem().bind(getMsg(position), dcChat, glideRequests, batchSelected, recipient, pulseHighlight); + holder.getItem().bind(getMsg(position), dcChat, glideRequests, batchSelected, recipient, pulseHighlight, playbackViewModel); } @Override diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index d28040736..d8844d8b5 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -21,6 +21,7 @@ import android.annotation.SuppressLint; import android.app.Activity; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; @@ -38,8 +39,13 @@ import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.media3.session.MediaController; +import androidx.media3.session.SessionToken; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnScrollListener; @@ -49,8 +55,10 @@ import com.b44t.messenger.DcContext; import com.b44t.messenger.DcEvent; import com.b44t.messenger.DcMsg; +import com.google.common.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.components.reminder.DozeReminder; import org.thoughtcrime.securesms.connect.DcEventCenter; import org.thoughtcrime.securesms.connect.DcHelper; @@ -60,6 +68,7 @@ import org.thoughtcrime.securesms.reactions.ReactionsDetailsFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.relay.EditRelayActivity; +import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.AccessibilityUtil; import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; @@ -103,12 +112,15 @@ public class ConversationFragment extends MessageSelectorFragment private AddReactionView addReactionView; private TextView noMessageTextView; private Timer reloadTimer; + private @Nullable MediaController mediaController; + private ListenableFuture mediaControllerFuture; + private AudioPlaybackViewModel playbackViewModel; public boolean isPaused; private Debouncer markseenDebouncer; private Rpc rpc; - @Override + @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); rpc = DcHelper.getRpc(getContext()); @@ -130,6 +142,8 @@ public void run() { Util.runOnMain(ConversationFragment.this::reloadList); } }, 60 * 1000, 60 * 1000); + + playbackViewModel = new ViewModelProvider(this).get(AudioPlaybackViewModel.class); } @Override @@ -162,13 +176,31 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, } @Override - public void onActivityCreated(Bundle bundle) { - super.onActivityCreated(bundle); + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); initializeResources(); initializeListAdapter(); } + private void initializeMediaController() { + Context context = getContext(); + if (context == null) return; + + SessionToken sessionToken = new SessionToken(context, + new ComponentName(context, AudioPlaybackService.class)); + mediaControllerFuture = new MediaController.Builder(context, sessionToken) + .buildAsync(); + mediaControllerFuture.addListener(() -> { + try { + mediaController = mediaControllerFuture.get(); + playbackViewModel.setMediaController(mediaController); + } catch (Exception e) { + Log.e(TAG, "Error connecting to audio playback service", e); + } + }, ContextCompat.getMainExecutor(context)); + } + private void setNoMessageText() { DcChat dcChat = getListAdapter().getChat(); if(dcChat.isMultiUser()){ @@ -200,6 +232,11 @@ else if(!dcChat.isEncrypted()) { public void onDestroy() { DcHelper.getEventCenter(getContext()).removeObservers(this); reloadTimer.cancel(); + if (mediaController != null) { + MediaController.releaseFuture(mediaControllerFuture); + mediaController = null; + playbackViewModel.setMediaController(null); + } super.onDestroy(); } @@ -290,6 +327,7 @@ private void initializeListAdapter() { if (this.recipient != null && this.chatId != -1) { ConversationAdapter adapter = new ConversationAdapter(getActivity(), this.recipient.getChat(), GlideApp.with(this), selectionClickListener, this.recipient); list.setAdapter(adapter); + adapter.setPlaybackViewModel(playbackViewModel); if (dateDecoration != null) { list.removeItemDecoration(dateDecoration); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index ab0c9ab0a..9a9d2f3da 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -41,8 +41,8 @@ import com.b44t.messenger.DcContact; import com.b44t.messenger.DcMsg; -import org.thoughtcrime.securesms.audio.AudioSlidePlayer; -import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.BorderlessImageView; import org.thoughtcrime.securesms.components.CallItemView; @@ -180,9 +180,10 @@ public void bind(@NonNull DcMsg messageRecord, @NonNull GlideRequests glideRequests, @NonNull Set batchSelected, @NonNull Recipient recipients, - boolean pulseHighlight) + boolean pulseHighlight, + @Nullable AudioPlaybackViewModel playbackViewModel) { - bind(messageRecord, dcChat, batchSelected, pulseHighlight, recipients); + bindPartial(messageRecord, dcChat, batchSelected, pulseHighlight, recipients); this.glideRequests = glideRequests; this.showSender = ((dcChat.isMultiUser() || dcChat.isSelfTalk()) && !messageRecord.isOutgoing()) || messageRecord.getOverrideSenderName() != null; @@ -203,7 +204,7 @@ public void bind(@NonNull DcMsg messageRecord, setGutterSizes(messageRecord, showSender); setMessageShape(messageRecord); - setMediaAttributes(messageRecord, showSender); + setMediaAttributes(messageRecord, showSender, playbackViewModel); setBodyText(messageRecord); setBubbleState(messageRecord); setContactPhoto(); @@ -478,7 +479,8 @@ else if (messageRecord.hasHtml()) { } private void setMediaAttributes(@NonNull DcMsg messageRecord, - boolean showSender) + boolean showSender, + AudioPlaybackViewModel playbackViewModel) { if (hasAudio(messageRecord)) { audioViewStub.get().setVisibility(View.VISIBLE); @@ -492,6 +494,7 @@ private void setMediaAttributes(@NonNull DcMsg messageRecord, //noinspection ConstantConditions int duration = messageRecord.getDuration(); + audioViewStub.get().setPlaybackViewModel(playbackViewModel); audioViewStub.get().setAudio(new AudioSlide(context, messageRecord), duration); audioViewStub.get().setOnClickListener(passthroughClickListener); audioViewStub.get().setOnLongClickListener(passthroughClickListener); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java index 4e484a368..c9d58817d 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java @@ -15,6 +15,7 @@ import org.json.JSONObject; import org.thoughtcrime.securesms.components.DeliveryStatusView; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.JsonUtils; @@ -61,9 +62,10 @@ public void bind(@NonNull DcMsg messageRecord, @NonNull GlideRequests glideRequests, @NonNull Set batchSelected, @NonNull Recipient conversationRecipient, - boolean pulseUpdate) + boolean pulseUpdate, + @Nullable AudioPlaybackViewModel playbackViewModel) { - bind(messageRecord, dcChat, batchSelected, pulseUpdate, conversationRecipient); + bindPartial(messageRecord, dcChat, batchSelected, pulseUpdate, conversationRecipient); setGenericInfoRecord(messageRecord); } diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java new file mode 100644 index 000000000..525bc71e8 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.components.audioplay; + +import android.net.Uri; + +import androidx.annotation.Nullable; + +public class AudioPlaybackState { + private final @Nullable Uri audioUri; + private final PlaybackStatus status; + private final long currentPosition; + private final long duration; + + public enum PlaybackStatus { + IDLE, + LOADING, + PLAYING, + PAUSED, + ERROR + } + + public AudioPlaybackState(@Nullable Uri audioUri, + PlaybackStatus status, + long currentPosition, + long duration) { + this.audioUri = audioUri; + this.status = status; + this.currentPosition = currentPosition; + this.duration = duration; + } + + public static AudioPlaybackState idle() { + return new AudioPlaybackState(null, PlaybackStatus.IDLE, 0, 0); + } + + @Nullable + public Uri getAudioUri() { + return audioUri; + } + + public PlaybackStatus getStatus() { + return status; + } + + public long getCurrentPosition() { + return currentPosition; + } + + public long getDuration() { + return duration; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java new file mode 100644 index 000000000..1c30308c5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -0,0 +1,187 @@ +package org.thoughtcrime.securesms.components.audioplay; + +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; +import androidx.media3.session.MediaController; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.concurrent.Future; + + +public class AudioPlaybackViewModel extends ViewModel { + private final MutableLiveData playbackState; + private @Nullable MediaController mediaController; + private final Handler handler; + private boolean isUserSeeking = false; + + public AudioPlaybackViewModel() { + playbackState = new MutableLiveData<>(AudioPlaybackState.idle()); + handler = new Handler(Looper.getMainLooper()); + } + + public LiveData getPlaybackState() { + return playbackState; + } + + public void setMediaController(@Nullable MediaController controller) { + this.mediaController = controller; + setupPlayerListener(); + } + + // Public methods + public void playAudio(Uri audioUri) { + if (mediaController == null) return; + + AudioPlaybackState currentState = playbackState.getValue(); + + updateState(audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0); + + // Set media item if we have a different audio + if (currentState != null && currentState.getAudioUri() != null + && !currentState.getAudioUri().equals(audioUri)) { + MediaItem mediaItem = MediaItem.fromUri(audioUri); + mediaController.setMediaItem(mediaItem); + mediaController.prepare(); + } + + play(); + } + + public void pause() { + if (mediaController != null) { + mediaController.pause(); + + stopUpdateProgress(); + } + } + + public void play() { + if (mediaController != null) { + mediaController.play(); + } + } + + public void seekTo(long position) { + if (mediaController != null) { + mediaController.seekTo(position); + } + } + + // Shouldn't be needed for voice messages, but may be useful later + public void stop() { + if (mediaController != null) { + mediaController.stop(); + } + stopUpdateProgress(); + playbackState.setValue(AudioPlaybackState.idle()); + } + + public void setUserSeeking(boolean isUserSeeking) { + this.isUserSeeking = isUserSeeking; + } + + // Private methods + private void setupPlayerListener() { + if (mediaController == null) return; + + mediaController.addListener(new Player.Listener() { + @Override + public void onEvents(Player player, Player.Events events) { + if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) { + if (player.isPlaying()) { + startUpdateProgress(); + } else { + stopUpdateProgress(); + } + updateCurrentState(); + } + if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + if (player.getPlaybackState() == Player.STATE_READY) { + updateCurrentState(); + } else if (player.getPlaybackState() == Player.STATE_ENDED) { + // This is to prevent automatically playing after the audio + // has been play to the end once, then user dragged the seek bar again + mediaController.setPlayWhenReady(false); + } + } + if (events.containsAny(Player.EVENT_PLAYER_ERROR)) { + updateCurrentAudioState(AudioPlaybackState.PlaybackStatus.ERROR, 0, 0); + } + } + }); + } + + private void updateCurrentState() { + if (mediaController == null) return; + + AudioPlaybackState.PlaybackStatus status; + if (mediaController.isPlaying()) { + status = AudioPlaybackState.PlaybackStatus.PLAYING; + } else if (mediaController.getPlaybackState() == Player.STATE_READY + || mediaController.getPlaybackState() == Player.STATE_ENDED) { + status = AudioPlaybackState.PlaybackStatus.PAUSED; + } else { + status = AudioPlaybackState.PlaybackStatus.IDLE; + } + + updateCurrentAudioState(status, + mediaController.getCurrentPosition(), + mediaController.getDuration()); + } + + private void updateState(Uri audioUri, + AudioPlaybackState.PlaybackStatus status, + long position, + long duration) { + playbackState.setValue(new AudioPlaybackState( + audioUri, status, position, duration + )); + } + + private void updateCurrentAudioState(AudioPlaybackState.PlaybackStatus status, + long position, + long duration) { + AudioPlaybackState current = playbackState.getValue(); + + if (current != null) { + updateState(current.getAudioUri(), status, position, duration); + } + } + + // Progress tracking + private final Runnable progressRunnable = new Runnable() { + @Override + public void run() { + if (mediaController != null && mediaController.isPlaying() && !isUserSeeking) { + updateCurrentAudioState(AudioPlaybackState.PlaybackStatus.PLAYING, + mediaController.getCurrentPosition(), + mediaController.getDuration()); + handler.postDelayed(this, 100); // Update every 100ms + } + } + }; + + private void startUpdateProgress() { + stopUpdateProgress(); + handler.post(progressRunnable); + } + + private void stopUpdateProgress() { + handler.removeCallbacks(progressRunnable); + } + + @Override + protected void onCleared() { + super.onCleared(); + stopUpdateProgress(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java similarity index 89% rename from src/main/java/org/thoughtcrime/securesms/components/AudioView.java rename to src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index 5fb8099cc..043fd3a20 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.components; +package org.thoughtcrime.securesms.components.audioplay; import android.content.ComponentName; import android.content.Context; @@ -21,6 +21,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import androidx.lifecycle.Observer; import androidx.media3.common.MediaItem; import androidx.media3.common.Player; import androidx.media3.session.MediaController; @@ -29,6 +30,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AnimatingToggle; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.DateUtils; @@ -54,6 +56,9 @@ public class AudioView extends FrameLayout { private int backwardsCounter; private Uri audioUri; private ListenableFuture mediaControllerFuture; + private AudioPlaybackViewModel viewModel; + + private final Observer stateObserver = this::onPlaybackStateChanged; public AudioView(Context context) { this(context, null); @@ -210,10 +215,17 @@ private void updateUIForPause() { @Override protected void onDetachedFromWindow() { - releaseController(); + if (viewModel != null) { + viewModel.getPlaybackState().removeObserver(stateObserver); + } super.onDetachedFromWindow(); } + public void setPlaybackViewModel(AudioPlaybackViewModel viewModel) { + // For now ViewModel is used directly for simplicity, since there is no reuse + this.viewModel = viewModel; + } + public void setAudio(final @NonNull AudioSlide audio, int duration) { controlToggle.displayQuick(playButton); @@ -357,4 +369,35 @@ private void togglePauseToPlay() { playButton.setImageDrawable(pauseToPlayDrawable); pauseToPlayDrawable.start(); } + + private void onPlaybackStateChanged(AudioPlaybackState state) { + if (audioUri == null || state == null) return; + + // Check if this state is about this message + boolean isThisMessage = audioUri.equals(state.getAudioUri()); + + if (isThisMessage) { + updateUIForPlaybackState(state); + } else { + updateUIForPause(); + } + } + + // TODO: also need to update seeking + private void updateUIForPlaybackState(AudioPlaybackState state) { + switch (state.getStatus()) { + case PLAYING: + updateUIForPlay(); + break; + + case PAUSED: + updateUIForPause(); + break; + + case LOADING: + case ERROR: + // No special handling yet + break; + } + } } diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 5e7242058..98eed0998 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -50,7 +50,7 @@ import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.UriAttachment; import org.thoughtcrime.securesms.audio.AudioSlidePlayer; -import org.thoughtcrime.securesms.components.AudioView; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.RemovableEditableMediaView; import org.thoughtcrime.securesms.components.ThumbnailView; diff --git a/src/main/res/layout/audio_view.xml b/src/main/res/layout/audio_view.xml index 0e2206351..c8c2a8510 100644 --- a/src/main/res/layout/audio_view.xml +++ b/src/main/res/layout/audio_view.xml @@ -1,7 +1,7 @@ + tools:context="org.thoughtcrime.securesms.components.audioplay.AudioView"> - - - - Date: Tue, 27 Jan 2026 20:58:22 +0100 Subject: [PATCH 04/19] Finish ViewModel for audio playback; Refine UI set up for AudioView --- build.gradle | 2 +- .../securesms/BaseConversationItem.java | 2 + .../securesms/ConversationAdapter.java | 2 +- .../securesms/ConversationFragment.java | 5 +- .../audioplay/AudioPlaybackViewModel.java | 38 ++- .../components/audioplay/AudioView.java | 306 ++++++------------ .../securesms/mms/AttachmentManager.java | 2 +- src/main/res/layout/audio_view.xml | 56 ++-- 8 files changed, 154 insertions(+), 259 deletions(-) diff --git a/build.gradle b/build.gradle index 54a81890b..27216f056 100644 --- a/build.gradle +++ b/build.gradle @@ -173,7 +173,7 @@ dependencies { implementation 'androidx.work:work-runtime:2.9.1' implementation 'androidx.emoji2:emoji2-emojipicker:1.5.0' implementation 'com.google.guava:guava:31.1-android' - implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // TODO: I am keeping the exoplayer dependencies for Video, but we shall migrate them at some point + implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // FIXME: I am keeping the exoplayer dependencies for Video, but we shall migrate them at some point implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' implementation "androidx.media3:media3-exoplayer:$media3_version" implementation "androidx.media3:media3-session:$media3_version" diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java index 01b798e79..6946f8b4f 100644 --- a/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java @@ -115,6 +115,8 @@ protected class ClickListener implements View.OnClickListener { public void onClick(View v) { if (!shouldInterceptClicks(messageRecord) && parent != null) { + // The click workaround on ConversationItem shall be revised. + // In fact, it is probably better rethinking accessibility approach for the items. if (batchSelected.isEmpty() && Util.isTouchExplorationEnabled(context)) { BaseConversationItem.this.onAccessibilityClick(); } diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java index 666de291c..a5d57ee2a 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java @@ -60,7 +60,7 @@ * @author Moxie Marlinspike * */ -// TODO: this breaks type checks, that is why there are so many casts. +// FIXME: this breaks type checks, that is why there are so many casts. public class ConversationAdapter extends RecyclerView.Adapter implements StickyHeaderDecoration.StickyHeaderAdapter diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index d8844d8b5..6ca4e5d1f 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -120,7 +120,7 @@ public class ConversationFragment extends MessageSelectorFragment private Debouncer markseenDebouncer; private Rpc rpc; - @Override + @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); rpc = DcHelper.getRpc(getContext()); @@ -143,7 +143,8 @@ public void run() { } }, 60 * 1000, 60 * 1000); - playbackViewModel = new ViewModelProvider(this).get(AudioPlaybackViewModel.class); + playbackViewModel = new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class); + initializeMediaController(); } @Override diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java index 1c30308c5..c0ccda03a 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -34,11 +34,15 @@ public LiveData getPlaybackState() { public void setMediaController(@Nullable MediaController controller) { this.mediaController = controller; + if (mediaController != null && mediaController.isPlaying()) { + startUpdateProgress(); + } + updateCurrentState(true); setupPlayerListener(); } // Public methods - public void playAudio(Uri audioUri) { + public void loadAudioAndPlay(Uri audioUri) { if (mediaController == null) return; AudioPlaybackState currentState = playbackState.getValue(); @@ -46,8 +50,9 @@ public void playAudio(Uri audioUri) { updateState(audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0); // Set media item if we have a different audio - if (currentState != null && currentState.getAudioUri() != null - && !currentState.getAudioUri().equals(audioUri)) { + if (currentState != null && ( + currentState.getAudioUri() == null || + currentState.getAudioUri() != null && !currentState.getAudioUri().equals(audioUri))) { MediaItem mediaItem = MediaItem.fromUri(audioUri); mediaController.setMediaItem(mediaItem); mediaController.prepare(); @@ -59,8 +64,6 @@ public void playAudio(Uri audioUri) { public void pause() { if (mediaController != null) { mediaController.pause(); - - stopUpdateProgress(); } } @@ -76,7 +79,7 @@ public void seekTo(long position) { } } - // Shouldn't be needed for voice messages, but may be useful later + // Shouldn't need it for voice messages, but may be useful later public void stop() { if (mediaController != null) { mediaController.stop(); @@ -102,11 +105,11 @@ public void onEvents(Player player, Player.Events events) { } else { stopUpdateProgress(); } - updateCurrentState(); + updateCurrentState(false); } if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { if (player.getPlaybackState() == Player.STATE_READY) { - updateCurrentState(); + updateCurrentState(false); } else if (player.getPlaybackState() == Player.STATE_ENDED) { // This is to prevent automatically playing after the audio // has been play to the end once, then user dragged the seek bar again @@ -120,7 +123,7 @@ public void onEvents(Player player, Player.Events events) { }); } - private void updateCurrentState() { + private void updateCurrentState(boolean queryCurrentUri) { if (mediaController == null) return; AudioPlaybackState.PlaybackStatus status; @@ -133,7 +136,18 @@ private void updateCurrentState() { status = AudioPlaybackState.PlaybackStatus.IDLE; } - updateCurrentAudioState(status, + Uri currentUri = null; + if (playbackState.getValue() != null) { + currentUri = playbackState.getValue().getAudioUri(); + } + if (queryCurrentUri || playbackState.getValue() == null) { + MediaItem item = mediaController.getCurrentMediaItem(); + if (item != null && item.localConfiguration != null) { + currentUri = item.localConfiguration.uri; + } + } + updateState(currentUri, + status, mediaController.getCurrentPosition(), mediaController.getDuration()); } @@ -165,7 +179,7 @@ public void run() { updateCurrentAudioState(AudioPlaybackState.PlaybackStatus.PLAYING, mediaController.getCurrentPosition(), mediaController.getDuration()); - handler.postDelayed(this, 100); // Update every 100ms + handler.postDelayed(this, 100); } } }; @@ -181,7 +195,7 @@ private void stopUpdateProgress() { @Override protected void onCleared() { - super.onCleared(); stopUpdateProgress(); + super.onCleared(); } } diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index 043fd3a20..3a10e46ed 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -1,15 +1,9 @@ package org.thoughtcrime.securesms.components.audioplay; -import android.content.ComponentName; import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.PorterDuff; import android.graphics.Rect; -import android.graphics.drawable.AnimatedVectorDrawable; -import android.media.AudioManager; +import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.Handler; -import android.os.Looper; import android.util.AttributeSet; import android.util.Log; import android.view.View; @@ -19,20 +13,13 @@ import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; +import androidx.appcompat.content.res.AppCompatResources; import androidx.lifecycle.Observer; -import androidx.media3.common.MediaItem; -import androidx.media3.common.Player; -import androidx.media3.session.MediaController; -import androidx.media3.session.SessionToken; - -import com.google.common.util.concurrent.ListenableFuture; +import androidx.vectordrawable.graphics.drawable.Animatable2Compat; +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.AnimatingToggle; import org.thoughtcrime.securesms.mms.AudioSlide; -import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.DateUtils; @@ -40,25 +27,20 @@ public class AudioView extends FrameLayout { private static final String TAG = AudioView.class.getSimpleName(); - private final @NonNull AnimatingToggle controlToggle; - private final @NonNull ImageView playButton; - private final @NonNull ImageView pauseButton; + private final @NonNull ImageView playPauseButton; + private final AnimatedVectorDrawableCompat playToPauseDrawable; + private final AnimatedVectorDrawableCompat pauseToPlayDrawable; + private final Drawable playDrawable; + private final Drawable pauseDrawable; private final @NonNull SeekBar seekBar; private final @NonNull TextView timestamp; private final @NonNull TextView title; private final @NonNull View mask; - private @Nullable MediaController mediaController; - private Handler progressHandler; - private Runnable progressUpdater; - private boolean isUserSeeking = false; - private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; - private int backwardsCounter; private Uri audioUri; - private ListenableFuture mediaControllerFuture; private AudioPlaybackViewModel viewModel; - private final Observer stateObserver = this::onPlaybackStateChanged; + private boolean isPlaying; public AudioView(Context context) { this(context, null); @@ -72,108 +54,64 @@ public AudioView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); inflate(context, R.layout.audio_view, this); - this.controlToggle = (AnimatingToggle) findViewById(R.id.control_toggle); - this.playButton = (ImageView) findViewById(R.id.play); - this.pauseButton = (ImageView) findViewById(R.id.pause); - this.seekBar = (SeekBar) findViewById(R.id.seek); - this.timestamp = (TextView) findViewById(R.id.timestamp); - this.title = (TextView) findViewById(R.id.title); + this.playPauseButton = findViewById(R.id.play_pause); + this.seekBar = findViewById(R.id.seek); + this.timestamp = findViewById(R.id.timestamp); + this.title = findViewById(R.id.title); this.mask = findViewById(R.id.interception_mask); this.timestamp.setText("00:00"); - this.playButton.setImageDrawable(context.getDrawable(R.drawable.play_icon)); - this.pauseButton.setImageDrawable(context.getDrawable(R.drawable.pause_icon)); - this.playButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp)); - this.pauseButton.setBackground(context.getDrawable(R.drawable.ic_circle_fill_white_48dp)); + // Load drawables once + this.playToPauseDrawable = AnimatedVectorDrawableCompat.create( + getContext(), R.drawable.play_to_pause_animation); + this.pauseToPlayDrawable = AnimatedVectorDrawableCompat.create( + getContext(), R.drawable.pause_to_play_animation); + this.playDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.play_icon); + this.pauseDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.pause_icon); - progressHandler = new Handler(Looper.getMainLooper()); - - setTint(getContext().getResources().getColor(R.color.audio_icon)); + Animatable2Compat.AnimationCallback animationCallback = new Animatable2Compat.AnimationCallback() { + @Override + public void onAnimationEnd(Drawable drawable) { + Drawable endState = isPlaying ? pauseDrawable : playDrawable; + playPauseButton.setImageDrawable(endState); + } + }; + if (playToPauseDrawable != null) { + playToPauseDrawable.registerAnimationCallback(animationCallback); + } + if (pauseToPlayDrawable != null) { + pauseToPlayDrawable.registerAnimationCallback(animationCallback); + } } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); - initializeController(); - } - - private void initializeController() { - Context context = getContext(); - SessionToken sessionToken = new SessionToken(context, - new ComponentName(context, AudioPlaybackService.class)); - mediaControllerFuture = new MediaController.Builder(context, sessionToken) - .buildAsync(); - mediaControllerFuture.addListener(() -> { - try { - mediaController = mediaControllerFuture.get(); - setupControls(); - updateUIFromController(); - } catch (Exception e) { - Log.e(TAG, "Error connecting to audio playback service", e); - } - }, ContextCompat.getMainExecutor(context)); + setupControls(); } - private void updateUIFromController() { - if (mediaController == null) return; + private void setupControls() { + playPauseButton.setOnClickListener(v -> { + Log.w(TAG, "playPauseButton onClick"); - if (mediaController.isPlaying()) { - updateUIForPlay(); - } else if (!mediaController.isPlaying()) { - updateUIForPause(); - } - } + if (viewModel == null || audioUri == null) return; - private void setupControls() { - if (mediaController == null) return; - if (audioUri == null) return; + AudioPlaybackState state = viewModel.getPlaybackState().getValue(); - mediaController.addListener(new Player.Listener() { - @Override - public void onEvents(Player player, Player.Events events) { - if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) { - if (player.isPlaying()) { - updateUIForPlay(); - } else if (!player.isPlaying()) { - updateUIForPause(); - } - } - if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) { - if (player.getPlaybackState() == Player.STATE_ENDED - && player.getAvailableCommands().contains(Player.COMMAND_PLAY_PAUSE)) { - mediaController.setPlayWhenReady(false); - } + if (state != null && audioUri.equals(state.getAudioUri())) { + // Same audio + if (state.getStatus() == AudioPlaybackState.PlaybackStatus.PLAYING) { + viewModel.pause(); + } else { + viewModel.play(); } - } - }); - - playButton.setOnClickListener(v -> { - Log.w(TAG, "playButton onClick"); - MediaItem currentItem = mediaController.getCurrentMediaItem(); - if (currentItem == null || - (currentItem.localConfiguration != null && !audioUri.equals(currentItem.localConfiguration.uri))) { - // Different media - MediaItem mediaItem = MediaItem.fromUri(audioUri); - mediaController.setMediaItem(mediaItem); - mediaController.prepare(); - mediaController.play(); - updateUIForPlay(); } else { - // Same media, just resume - if (!mediaController.isPlaying()) { - mediaController.play(); - updateUIForPlay(); - } - } - }); - pauseButton.setOnClickListener(v -> { - Log.w(TAG, "pauseButton onClick"); - if (mediaController.isPlaying()) { - mediaController.pause(); - updateUIForPause(); + // Different audio + viewModel.loadAudioAndPlay(audioUri); } }); + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { @@ -184,54 +122,51 @@ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { @Override public void onStartTrackingTouch(SeekBar seekBar) { - isUserSeeking = true; - stopUpdateProgress(); + viewModel.setUserSeeking(true); } @Override public void onStopTrackingTouch(SeekBar seekBar) { - isUserSeeking = false; - if (mediaController != null) { - mediaController.seekTo(seekBar.getProgress()); - } - startUpdateProgress(); + viewModel.setUserSeeking(false); + viewModel.seekTo(seekBar.getProgress()); } }); } - private void updateUIForPlay() { - if (pauseButton.getVisibility() != View.VISIBLE) { - togglePlayToPause(); - } - startUpdateProgress(); - } - - private void updateUIForPause() { - if (playButton.getVisibility() != View.VISIBLE) { - togglePauseToPlay(); - } - stopUpdateProgress(); - } - @Override protected void onDetachedFromWindow() { if (viewModel != null) { viewModel.getPlaybackState().removeObserver(stateObserver); } + if (playToPauseDrawable != null) { + playToPauseDrawable.clearAnimationCallbacks(); + } + if (pauseToPlayDrawable != null) { + pauseToPlayDrawable.clearAnimationCallbacks(); + } super.onDetachedFromWindow(); } public void setPlaybackViewModel(AudioPlaybackViewModel viewModel) { - // For now ViewModel is used directly for simplicity, since there is no reuse + if (this.viewModel != null) { + this.viewModel.getPlaybackState().removeObserver(stateObserver); + } + + // ViewModel is used directly for simplicity, since there is no reuse yet this.viewModel = viewModel; + + if (viewModel != null) { + viewModel.getPlaybackState().observeForever(stateObserver); + } } public void setAudio(final @NonNull AudioSlide audio, int duration) { - controlToggle.displayQuick(playButton); + audioUri = audio.getUri(); + playPauseButton.setImageDrawable(playDrawable); + seekBar.setEnabled(true); seekBar.setProgress(0); - audioUri = audio.getUri(); timestamp.setText(DateUtils.getFormatedDuration(duration)); if(audio.asAttachment().isVoiceNote() || !audio.getFileName().isPresent()) { @@ -253,16 +188,11 @@ public void setOnClickListener(OnClickListener listener) { public void setOnLongClickListener(OnLongClickListener listener) { super.setOnLongClickListener(listener); this.mask.setOnLongClickListener(listener); - this.playButton.setOnLongClickListener(listener); - this.pauseButton.setOnLongClickListener(listener); + this.playPauseButton.setOnLongClickListener(listener); } public void togglePlay() { - if (this.playButton.getVisibility() == View.VISIBLE) { - playButton.performClick(); - } else { - pauseButton.performClick(); - } + playPauseButton.performClick(); } public String getDescription() { @@ -285,47 +215,14 @@ public void setDuration(int duration) { this.timestamp.setText(DateUtils.getFormatedDuration(duration)); } - public void releaseController() { - if (mediaController != null && mediaControllerFuture != null) { - MediaController.releaseFuture(mediaControllerFuture); - } - } - - // Poll progress and update UI - private void startUpdateProgress() { - if (progressUpdater == null) { - progressUpdater = new Runnable() { - @Override - public void run() { - if (mediaController != null && !isUserSeeking) { - updateProgress(); - // Update every 100ms for smooth progress - progressHandler.postDelayed(this, 100); - } - } - }; - } - progressHandler.removeCallbacks(progressUpdater); - progressHandler.post(progressUpdater); - } - - private void stopUpdateProgress() { - if (progressUpdater != null) { - progressHandler.removeCallbacks(progressUpdater); - } - updateProgress(); // Make sure the UI is aligned even when update has stopped - } - - private void updateProgress() { - if (mediaController == null) return; - - long currentPosition = mediaController.getCurrentPosition(); - long duration = mediaController.getDuration(); + private void updateProgress(AudioPlaybackState state) { + int duration = (int) state.getDuration(); + int position = (int) state.getCurrentPosition(); if (duration > 0) { - seekBar.setMax((int) duration); - seekBar.setProgress((int) currentPosition); - timestamp.setText(DateUtils.getFormatedDuration(currentPosition)); + seekBar.setMax(duration); + seekBar.setProgress(position); + timestamp.setText(DateUtils.getFormatedDuration(position)); } } @@ -333,15 +230,6 @@ public void disablePlayer(boolean disable) { this.mask.setVisibility(disable? View.VISIBLE : View.GONE); } - public void setTint(int foregroundTint) { - this.playButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint)); - this.pauseButton.setBackgroundTintList(ColorStateList.valueOf(foregroundTint)); - - this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); - - this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); - } - public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) { seekBar.getGlobalVisibleRect(rect); } @@ -354,20 +242,27 @@ private double getProgress() { } } - private void togglePlayToPause() { - controlToggle.displayQuick(pauseButton); + private void togglePlayPause(boolean expectedPlaying) { + isPlaying = expectedPlaying; + Drawable expectedDrawable = expectedPlaying ? pauseDrawable : playDrawable; - AnimatedVectorDrawable playToPauseDrawable = (AnimatedVectorDrawable) getContext().getDrawable(R.drawable.play_to_pause_animation); - pauseButton.setImageDrawable(playToPauseDrawable); - playToPauseDrawable.start(); - } + boolean isAnimating = false; + Drawable currentDrawable = playPauseButton.getDrawable(); + if (currentDrawable instanceof AnimatedVectorDrawableCompat) { + isAnimating = ((AnimatedVectorDrawableCompat) currentDrawable).isRunning(); + } + if (!isAnimating && playPauseButton.getDrawable() != expectedDrawable) { + AnimatedVectorDrawableCompat animDrawable = expectedPlaying ? playToPauseDrawable : pauseToPlayDrawable; + String contentDescription = getContext().getString( + expectedPlaying ? R.string.menu_pause : R.string.menu_play); - private void togglePauseToPlay() { - controlToggle.displayQuick(playButton); + if (animDrawable != null) { + playPauseButton.setImageDrawable(animDrawable); + playPauseButton.setContentDescription(contentDescription); - AnimatedVectorDrawable pauseToPlayDrawable = (AnimatedVectorDrawable) getContext().getDrawable(R.drawable.pause_to_play_animation); - playButton.setImageDrawable(pauseToPlayDrawable); - pauseToPlayDrawable.start(); + animDrawable.start(); + } + } } private void onPlaybackStateChanged(AudioPlaybackState state) { @@ -379,19 +274,20 @@ private void onPlaybackStateChanged(AudioPlaybackState state) { if (isThisMessage) { updateUIForPlaybackState(state); } else { - updateUIForPause(); + togglePlayPause(false); } } - // TODO: also need to update seeking private void updateUIForPlaybackState(AudioPlaybackState state) { switch (state.getStatus()) { case PLAYING: - updateUIForPlay(); + togglePlayPause(true); + updateProgress(state); break; case PAUSED: - updateUIForPause(); + togglePlayPause(false); + updateProgress(state); break; case LOADING: diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 98eed0998..9883eae28 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -90,7 +90,7 @@ public class AttachmentManager { private RemovableEditableMediaView removableMediaView; private ThumbnailView thumbnail; - private AudioView audioView; // TODO: Model shall not directly manipulate View + private AudioView audioView; // TODO: set up ViewModel too private DocumentView documentView; private WebxdcView webxdcView; private VcardView vcardView; diff --git a/src/main/res/layout/audio_view.xml b/src/main/res/layout/audio_view.xml index c8c2a8510..7be81f6ed 100644 --- a/src/main/res/layout/audio_view.xml +++ b/src/main/res/layout/audio_view.xml @@ -1,7 +1,5 @@ - + - - - - - - - + + android:layout_gravity="center_vertical" + android:progressTint="@color/audio_icon" + android:progressTintMode="src_in" + android:thumbTint="@color/audio_icon" + android:thumbTintMode="src_in"/> Date: Wed, 28 Jan 2026 19:19:20 +0100 Subject: [PATCH 05/19] Allow going back to previous activity from the notification; Support AllMediaActivity --- .../securesms/AllMediaActivity.java | 60 ++++++++++++++ .../securesms/AllMediaDocumentsAdapter.java | 9 +- .../securesms/AllMediaDocumentsFragment.java | 18 +++- .../securesms/ConversationFragment.java | 34 ++++++-- .../components/audioplay/AudioView.java | 14 ---- .../securesms/mms/AttachmentManager.java | 44 +++++----- .../service/AudioPlaybackService.java | 82 ++++++++++++++++++- 7 files changed, 211 insertions(+), 50 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java index 7ab97e7b4..1035267dd 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java @@ -1,16 +1,26 @@ package org.thoughtcrime.securesms; +import android.content.ComponentName; +import android.content.Context; import android.os.Bundle; +import android.util.Log; import android.view.MenuItem; import android.view.ViewGroup; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.lifecycle.ViewModelProvider; +import androidx.media3.session.MediaController; +import androidx.media3.session.SessionCommand; +import androidx.media3.session.SessionToken; import androidx.viewpager.widget.ViewPager; import com.b44t.messenger.DcChat; @@ -18,9 +28,12 @@ import com.b44t.messenger.DcEvent; import com.b44t.messenger.DcMsg; import com.google.android.material.tabs.TabLayout; +import com.google.common.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.connect.DcEventCenter; import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; @@ -30,6 +43,7 @@ public class AllMediaActivity extends PassphraseRequiredActionBarActivity implements DcEventCenter.DcEventDelegate { + private static final String TAG = AllMediaActivity.class.getSimpleName(); public static final String CHAT_ID_EXTRA = "chat_id"; public static final String CONTACT_ID_EXTRA = "contact_id"; @@ -57,6 +71,10 @@ static class TabData { private TabLayout tabLayout; private ViewPager viewPager; + private @Nullable MediaController mediaController; + private ListenableFuture mediaControllerFuture; + private AudioPlaybackViewModel playbackViewModel; + @Override protected void onPreCreate() { dynamicTheme = new DynamicNoActionBarTheme(); @@ -91,11 +109,19 @@ protected void onCreate(Bundle bundle, boolean ready) { DcEventCenter eventCenter = DcHelper.getEventCenter(this); eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this); eventCenter.addObserver(DcContext.DC_EVENT_CONTACTS_CHANGED, this); + + playbackViewModel = new ViewModelProvider(this).get(AudioPlaybackViewModel.class); + initializeMediaController(); } @Override public void onDestroy() { DcHelper.getEventCenter(this).removeObservers(this); + if (mediaController != null) { + MediaController.releaseFuture(mediaControllerFuture); + mediaController = null; + playbackViewModel.setMediaController(null); + } super.onDestroy(); } @@ -124,6 +150,40 @@ private void initializeResources() { this.tabLayout = ViewUtil.findById(this, R.id.tab_layout); } + private void initializeMediaController() { + SessionToken sessionToken = new SessionToken(this, + new ComponentName(this, AudioPlaybackService.class)); + mediaControllerFuture = new MediaController.Builder(this, sessionToken) + .buildAsync(); + mediaControllerFuture.addListener(() -> { + try { + mediaController = mediaControllerFuture.get(); + addActivityContext( + this.getIntent().getExtras(), + this.getClass().getName() + ); + playbackViewModel.setMediaController(mediaController); + } catch (Exception e) { + Log.e(TAG, "Error connecting to audio playback service", e); + } + }, ContextCompat.getMainExecutor(this)); + } + + private void addActivityContext(Bundle extras, String activityClassName) { + if (mediaController == null) return; + + Bundle commandArgs = new Bundle(); + commandArgs.putString("activity_class", activityClassName); + if (extras != null) { + commandArgs.putAll(extras); + } + + SessionCommand updateContextCommand = + new SessionCommand("UPDATE_ACTIVITY_CONTEXT", Bundle.EMPTY); + + mediaController.sendCustomCommand(updateContextCommand, commandArgs); + } + private boolean isGlobalGallery() { return contactId==0 && chatId==0; } diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java index cb05a269e..350c962fa 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java @@ -11,6 +11,7 @@ import com.b44t.messenger.DcMsg; import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.WebxdcView; @@ -31,7 +32,8 @@ class AllMediaDocumentsAdapter extends StickyHeaderGridAdapter { private final ItemClickListener itemClickListener; private final Set selected; - private BucketedThreadMedia media; + private BucketedThreadMedia media; + private AudioPlaybackViewModel playbackViewModel; private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder { private final DocumentView documentView; @@ -71,6 +73,10 @@ public void setMedia(BucketedThreadMedia media) { this.media = media; } + public void setPlaybackViewModel(AudioPlaybackViewModel playbackViewModel) { + this.playbackViewModel = playbackViewModel; + } + @Override public StickyHeaderGridAdapter.HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int headerType) { return new HeaderHolder(LayoutInflater.from(context).inflate(R.layout.contact_selection_list_divider, parent, false)); @@ -97,6 +103,7 @@ public void onBindItemViewHolder(ItemViewHolder itemViewHolder, int section, int viewHolder.webxdcView.setVisibility(View.GONE); viewHolder.audioView.setVisibility(View.VISIBLE); + viewHolder.audioView.setPlaybackViewModel(playbackViewModel); viewHolder.audioView.setAudio((AudioSlide)slide, dcMsg.getDuration()); viewHolder.audioView.setOnClickListener(view -> itemClickListener.onMediaClicked(dcMsg)); viewHolder.audioView.setOnLongClickListener(view -> { itemClickListener.onMediaLongClicked(dcMsg); return true; }); diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java index afe3a653e..13a42565a 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java @@ -2,10 +2,12 @@ import static com.b44t.messenger.DcChat.DC_CHAT_NO_CHAT; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -14,20 +16,28 @@ import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; +import androidx.media3.session.MediaController; +import androidx.media3.session.SessionToken; import androidx.recyclerview.widget.RecyclerView; import com.b44t.messenger.DcContext; import com.b44t.messenger.DcEvent; import com.b44t.messenger.DcMsg; import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; +import com.google.common.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.connect.DcEventCenter; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader; +import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Set; @@ -72,9 +82,11 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, // add padding to avoid content hidden behind system bars ViewUtil.applyWindowInsets(recyclerView, true, false, true, true); - this.recyclerView.setAdapter(new AllMediaDocumentsAdapter(getContext(), - new BucketedThreadMediaLoader.BucketedThreadMedia(getContext()), - this)); + AllMediaDocumentsAdapter adapter = new AllMediaDocumentsAdapter(getContext(), + new BucketedThreadMediaLoader.BucketedThreadMedia(getContext()), + this); + this.recyclerView.setAdapter(adapter); + adapter.setPlaybackViewModel(new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class)); this.recyclerView.setLayoutManager(gridManager); this.recyclerView.setHasFixedSize(true); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index 6ca4e5d1f..032dea75e 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -43,8 +43,10 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; import androidx.media3.session.MediaController; +import androidx.media3.session.SessionCommand; import androidx.media3.session.SessionToken; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -185,24 +187,42 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { } private void initializeMediaController() { - Context context = getContext(); - if (context == null) return; + FragmentActivity activity = requireActivity(); - SessionToken sessionToken = new SessionToken(context, - new ComponentName(context, AudioPlaybackService.class)); - mediaControllerFuture = new MediaController.Builder(context, sessionToken) + SessionToken sessionToken = new SessionToken(activity, + new ComponentName(activity, AudioPlaybackService.class)); + mediaControllerFuture = new MediaController.Builder(activity, sessionToken) .buildAsync(); mediaControllerFuture.addListener(() -> { try { mediaController = mediaControllerFuture.get(); + addActivityContext( + activity.getIntent().getExtras(), + activity.getClass().getName() + ); playbackViewModel.setMediaController(mediaController); } catch (Exception e) { Log.e(TAG, "Error connecting to audio playback service", e); } - }, ContextCompat.getMainExecutor(context)); + }, ContextCompat.getMainExecutor(activity)); } - private void setNoMessageText() { + private void addActivityContext(Bundle extras, String activityClassName) { + if (mediaController == null) return; + + Bundle commandArgs = new Bundle(); + commandArgs.putString("activity_class", activityClassName); + if (extras != null) { + commandArgs.putAll(extras); + } + + SessionCommand updateContextCommand = + new SessionCommand("UPDATE_ACTIVITY_CONTEXT", Bundle.EMPTY); + + mediaController.sendCustomCommand(updateContextCommand, commandArgs); + } + + private void setNoMessageText() { DcChat dcChat = getListAdapter().getChat(); if(dcChat.isMultiUser()){ if (dcChat.isInBroadcast() || dcChat.isOutBroadcast()) { diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index 3a10e46ed..b684b87d8 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -209,12 +209,6 @@ public String getDescription() { return desc; } - @Deprecated - public void setDuration(int duration) { - if (getProgress()==0) - this.timestamp.setText(DateUtils.getFormatedDuration(duration)); - } - private void updateProgress(AudioPlaybackState state) { int duration = (int) state.getDuration(); int position = (int) state.getCurrentPosition(); @@ -234,14 +228,6 @@ public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) { seekBar.getGlobalVisibleRect(rect); } - private double getProgress() { - if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) { - return 0; - } else { - return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax(); - } - } - private void togglePlayPause(boolean expectedPlaying) { isPlaying = expectedPlaying; Drawable expectedDrawable = expectedPlaying ? pauseDrawable : playDrawable; diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 9883eae28..e62933017 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -90,7 +90,7 @@ public class AttachmentManager { private RemovableEditableMediaView removableMediaView; private ThumbnailView thumbnail; - private AudioView audioView; // TODO: set up ViewModel too +// private AudioView audioView; // TODO: is this used? private DocumentView documentView; private WebxdcView webxdcView; private VcardView vcardView; @@ -114,7 +114,7 @@ private void inflateStub() { View root = attachmentViewStub.get(); this.thumbnail = ViewUtil.findById(root, R.id.attachment_thumbnail); - this.audioView = ViewUtil.findById(root, R.id.attachment_audio); +// this.audioView = ViewUtil.findById(root, R.id.attachment_audio); this.documentView = ViewUtil.findById(root, R.id.attachment_document); this.webxdcView = ViewUtil.findById(root, R.id.attachment_webxdc); this.vcardView = ViewUtil.findById(root, R.id.attachment_vcard); @@ -283,26 +283,26 @@ protected void onPostExecute(@Nullable final Slide slide) { setAttachmentPresent(true); if (slide.hasAudio()) { - class SetDurationListener implements AudioSlidePlayer.Listener { - @Override - public void onStart() {} - - @Override - public void onStop() {} - - @Override - public void onProgress(AudioSlide slide, double progress, long millis) {} - - @Override - public void onReceivedDuration(int millis) { - ((AudioView) removableMediaView.getCurrent()).setDuration(millis); - } - } - AudioSlidePlayer audioSlidePlayer = AudioSlidePlayer.createFor(context, (AudioSlide) slide, new SetDurationListener()); - audioSlidePlayer.requestDuration(); - - audioView.setAudio((AudioSlide) slide, 0); - removableMediaView.display(audioView, false); +// class SetDurationListener implements AudioSlidePlayer.Listener { +// @Override +// public void onStart() {} +// +// @Override +// public void onStop() {} +// +// @Override +// public void onProgress(AudioSlide slide, double progress, long millis) {} +// +// @Override +// public void onReceivedDuration(int millis) { +// ((AudioView) removableMediaView.getCurrent()).setDuration(millis); +// } +// } +// AudioSlidePlayer audioSlidePlayer = AudioSlidePlayer.createFor(context, (AudioSlide) slide, new SetDurationListener()); +// audioSlidePlayer.requestDuration(); +// +// audioView.setAudio((AudioSlide) slide, 0); +// removableMediaView.display(audioView, false); result.set(true); } else if (slide.isVcard()) { vcardView.setVcard(glideRequests, (VcardSlide)slide, DcHelper.getRpc(context)); diff --git a/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java index 09122d806..5b592da3d 100644 --- a/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java +++ b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java @@ -2,19 +2,33 @@ import android.app.PendingIntent; import android.content.Intent; +import android.os.Bundle; import android.provider.MediaStore; +import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; +import androidx.media3.common.util.UnstableApi; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.session.MediaSession; import androidx.media3.session.MediaSessionService; +import androidx.media3.session.SessionCommand; +import androidx.media3.session.SessionCommands; +import androidx.media3.session.SessionResult; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import org.thoughtcrime.securesms.AllMediaDocumentsFragment; import org.thoughtcrime.securesms.ConversationListActivity; public class AudioPlaybackService extends MediaSessionService { + private static final String TAG = AudioPlaybackService.class.getSimpleName(); + private ExoPlayer player; private MediaSession session; @@ -33,18 +47,80 @@ public void onCreate() { .build(); // This is for click on the notification to go back to app - // TODO: Go to the right conversation Intent intent = new Intent(this, ConversationListActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity( + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent initialIntent = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE ); session = new MediaSession.Builder(this, player) - .setSessionActivity(pendingIntent) + .setSessionActivity(initialIntent) + .setCallback(new MediaSession.Callback() { + + @OptIn(markerClass = UnstableApi.class) + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, + MediaSession.ControllerInfo controller + ) { + SessionCommands sessionCommands = MediaSession + .ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .add(new SessionCommand("UPDATE_ACTIVITY_CONTEXT", new Bundle())) + .build(); + + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .build(); + } + + @NonNull + @Override + public ListenableFuture onCustomCommand( + MediaSession session, + MediaSession.ControllerInfo controller, + SessionCommand customCommand, + Bundle args + ) { + if ("UPDATE_ACTIVITY_CONTEXT".equals(customCommand.customAction)) { + updateSessionActivity(args); + } + return Futures.immediateFuture( + new SessionResult(SessionResult.RESULT_SUCCESS)); + } + }) .build(); } + @OptIn(markerClass = UnstableApi.class) + private void updateSessionActivity(Bundle args) { + try { + // Put all the original extras back into the intent + if (args != null && !args.isEmpty()) { + String activityClassName = args.getString("activity_class"); + args.remove("activity_class"); + + if (activityClassName != null) { + Class activityClass = Class.forName(activityClassName); + Intent intent = new Intent(this, activityClass); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.putExtras(args); + + PendingIntent pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + session.setSessionActivity(pendingIntent); + } + } + } catch (ClassNotFoundException e) { + Log.e(TAG, "Activity class not found", e); + } + } + @Nullable @Override public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) { From 11f3964bdc8ac51228d462707dc4e7f775e52d71 Mon Sep 17 00:00:00 2001 From: wch423 Date: Wed, 28 Jan 2026 20:02:18 +0100 Subject: [PATCH 06/19] Add support for attachment draft; Distinguish between different messages --- .../securesms/ConversationActivity.java | 60 ++++++++++++++++++- .../securesms/ConversationFragment.java | 49 +-------------- .../audioplay/AudioPlaybackState.java | 11 +++- .../audioplay/AudioPlaybackViewModel.java | 44 ++++++++++---- .../components/audioplay/AudioView.java | 9 ++- .../securesms/mms/AttachmentManager.java | 34 +++-------- 6 files changed, 116 insertions(+), 91 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index b11340984..97e0530e1 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -25,6 +25,7 @@ import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; import android.content.ClipData; +import android.content.ComponentName; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.res.Configuration; @@ -65,7 +66,13 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; import androidx.core.view.WindowCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.media3.session.MediaController; +import androidx.media3.session.SessionCommand; +import androidx.media3.session.SessionToken; import com.b44t.messenger.DcChat; import com.b44t.messenger.DcContact; @@ -86,6 +93,7 @@ import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener; import org.thoughtcrime.securesms.components.ScaleStableImageView; import org.thoughtcrime.securesms.components.SendButton; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; import org.thoughtcrime.securesms.connect.AccountManager; import org.thoughtcrime.securesms.connect.DcEventCenter; @@ -104,6 +112,7 @@ import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.scribbles.ScribbleActivity; +import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Prefs; @@ -126,6 +135,7 @@ import chat.delta.rpc.Rpc; import chat.delta.rpc.RpcException; +// TODO: why do we need customize Futures? import chat.delta.util.ListenableFuture; import chat.delta.util.SettableFuture; @@ -182,6 +192,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private MediaKeyboard emojiPicker; protected HidingLinearLayout quickAttachmentToggle; private InputPanel inputPanel; + private @Nullable MediaController mediaController; + private com.google.common.util.concurrent.ListenableFuture mediaControllerFuture; + private AudioPlaybackViewModel playbackViewModel; private ApplicationContext context; private Recipient recipient; @@ -214,6 +227,10 @@ protected void onCreate(Bundle state, boolean ready) { initializeActionBar(); initializeViews(); initializeResources(); + + playbackViewModel = new ViewModelProvider(this).get(AudioPlaybackViewModel.class); + initializeMediaController(); + initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener() { @Override public void onSuccess(Boolean result) { @@ -264,6 +281,40 @@ private void setDcEventListener() { } } + private void initializeMediaController() { + SessionToken sessionToken = new SessionToken(this, + new ComponentName(this, AudioPlaybackService.class)); + mediaControllerFuture = new MediaController.Builder(this, sessionToken) + .buildAsync(); + mediaControllerFuture.addListener(() -> { + try { + mediaController = mediaControllerFuture.get(); + addActivityContext( + this.getIntent().getExtras(), + this.getClass().getName() + ); + playbackViewModel.setMediaController(mediaController); + } catch (Exception e) { + Log.e(TAG, "Error connecting to audio playback service", e); + } + }, ContextCompat.getMainExecutor(this)); + } + + private void addActivityContext(Bundle extras, String activityClassName) { + if (mediaController == null) return; + + Bundle commandArgs = new Bundle(); + commandArgs.putString("activity_class", activityClassName); + if (extras != null) { + commandArgs.putAll(extras); + } + + SessionCommand updateContextCommand = + new SessionCommand("UPDATE_ACTIVITY_CONTEXT", Bundle.EMPTY); + + mediaController.sendCustomCommand(updateContextCommand, commandArgs); + } + @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); @@ -354,6 +405,11 @@ public void onConfigurationChanged(Configuration newConfig) { @Override protected void onDestroy() { DcHelper.getEventCenter(this).removeObservers(this); + if (mediaController != null) { + MediaController.releaseFuture(mediaControllerFuture); + mediaController = null; + playbackViewModel.setMediaController(null); + } super.onDestroy(); } @@ -1046,11 +1102,11 @@ private ListenableFuture setMedia(@Nullable Uri uri, @NonNull MediaType return new SettableFuture<>(false); } - return attachmentManager.setMedia(glideRequests, uri, null, mediaType, 0, 0, chatId); + return attachmentManager.setMedia(glideRequests, uri, null, mediaType, 0, 0, chatId, playbackViewModel); } private ListenableFuture setMedia(DcMsg msg, @NonNull MediaType mediaType) { - return attachmentManager.setMedia(glideRequests, Uri.fromFile(new File(msg.getFile())), msg, mediaType, 0, 0, chatId); + return attachmentManager.setMedia(glideRequests, Uri.fromFile(new File(msg.getFile())), msg, mediaType, 0, 0, chatId, playbackViewModel); } private void addAttachmentContactInfo(int contactId) { diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index 032dea75e..f2f1b2720 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -114,9 +114,6 @@ public class ConversationFragment extends MessageSelectorFragment private AddReactionView addReactionView; private TextView noMessageTextView; private Timer reloadTimer; - private @Nullable MediaController mediaController; - private ListenableFuture mediaControllerFuture; - private AudioPlaybackViewModel playbackViewModel; public boolean isPaused; private Debouncer markseenDebouncer; @@ -144,9 +141,6 @@ public void run() { Util.runOnMain(ConversationFragment.this::reloadList); } }, 60 * 1000, 60 * 1000); - - playbackViewModel = new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class); - initializeMediaController(); } @Override @@ -186,42 +180,6 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { initializeListAdapter(); } - private void initializeMediaController() { - FragmentActivity activity = requireActivity(); - - SessionToken sessionToken = new SessionToken(activity, - new ComponentName(activity, AudioPlaybackService.class)); - mediaControllerFuture = new MediaController.Builder(activity, sessionToken) - .buildAsync(); - mediaControllerFuture.addListener(() -> { - try { - mediaController = mediaControllerFuture.get(); - addActivityContext( - activity.getIntent().getExtras(), - activity.getClass().getName() - ); - playbackViewModel.setMediaController(mediaController); - } catch (Exception e) { - Log.e(TAG, "Error connecting to audio playback service", e); - } - }, ContextCompat.getMainExecutor(activity)); - } - - private void addActivityContext(Bundle extras, String activityClassName) { - if (mediaController == null) return; - - Bundle commandArgs = new Bundle(); - commandArgs.putString("activity_class", activityClassName); - if (extras != null) { - commandArgs.putAll(extras); - } - - SessionCommand updateContextCommand = - new SessionCommand("UPDATE_ACTIVITY_CONTEXT", Bundle.EMPTY); - - mediaController.sendCustomCommand(updateContextCommand, commandArgs); - } - private void setNoMessageText() { DcChat dcChat = getListAdapter().getChat(); if(dcChat.isMultiUser()){ @@ -253,11 +211,6 @@ else if(!dcChat.isEncrypted()) { public void onDestroy() { DcHelper.getEventCenter(getContext()).removeObservers(this); reloadTimer.cancel(); - if (mediaController != null) { - MediaController.releaseFuture(mediaControllerFuture); - mediaController = null; - playbackViewModel.setMediaController(null); - } super.onDestroy(); } @@ -348,6 +301,8 @@ private void initializeListAdapter() { if (this.recipient != null && this.chatId != -1) { ConversationAdapter adapter = new ConversationAdapter(getActivity(), this.recipient.getChat(), GlideApp.with(this), selectionClickListener, this.recipient); list.setAdapter(adapter); + AudioPlaybackViewModel playbackViewModel = + new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class); adapter.setPlaybackViewModel(playbackViewModel); if (dateDecoration != null) { diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java index 525bc71e8..09868ed25 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java @@ -5,6 +5,7 @@ import androidx.annotation.Nullable; public class AudioPlaybackState { + private final int msgId; private final @Nullable Uri audioUri; private final PlaybackStatus status; private final long currentPosition; @@ -18,10 +19,12 @@ public enum PlaybackStatus { ERROR } - public AudioPlaybackState(@Nullable Uri audioUri, + public AudioPlaybackState(int msgId, + @Nullable Uri audioUri, PlaybackStatus status, long currentPosition, long duration) { + this.msgId = msgId; this.audioUri = audioUri; this.status = status; this.currentPosition = currentPosition; @@ -29,7 +32,11 @@ public AudioPlaybackState(@Nullable Uri audioUri, } public static AudioPlaybackState idle() { - return new AudioPlaybackState(null, PlaybackStatus.IDLE, 0, 0); + return new AudioPlaybackState(0, null, PlaybackStatus.IDLE, 0, 0); + } + + public int getMsgId() { + return msgId; } @Nullable diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java index c0ccda03a..fb8796637 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -3,6 +3,7 @@ import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.util.Log; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; @@ -14,10 +15,14 @@ import com.google.common.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.connect.AccountManager; + import java.util.concurrent.Future; public class AudioPlaybackViewModel extends ViewModel { + private static final String TAG = AudioPlaybackViewModel.class.getSimpleName(); + private final MutableLiveData playbackState; private @Nullable MediaController mediaController; private final Handler handler; @@ -42,18 +47,21 @@ public void setMediaController(@Nullable MediaController controller) { } // Public methods - public void loadAudioAndPlay(Uri audioUri) { + public void loadAudioAndPlay(int msgId, Uri audioUri) { if (mediaController == null) return; AudioPlaybackState currentState = playbackState.getValue(); - updateState(audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0); + updateState(msgId, audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0); - // Set media item if we have a different audio + // Set media item if we have a different audio. Message ID doesn't matter here. if (currentState != null && ( currentState.getAudioUri() == null || currentState.getAudioUri() != null && !currentState.getAudioUri().equals(audioUri))) { - MediaItem mediaItem = MediaItem.fromUri(audioUri); + MediaItem mediaItem = new MediaItem.Builder() + .setMediaId(String.valueOf(msgId)) + .setUri(audioUri) + .build(); mediaController.setMediaItem(mediaItem); mediaController.prepare(); } @@ -123,7 +131,7 @@ public void onEvents(Player player, Player.Events events) { }); } - private void updateCurrentState(boolean queryCurrentUri) { + private void updateCurrentState(boolean queryPlaying) { if (mediaController == null) return; AudioPlaybackState.PlaybackStatus status; @@ -137,27 +145,39 @@ private void updateCurrentState(boolean queryCurrentUri) { } Uri currentUri = null; + int currentMsgId = 0; if (playbackState.getValue() != null) { + currentMsgId = playbackState.getValue().getMsgId(); currentUri = playbackState.getValue().getAudioUri(); } - if (queryCurrentUri || playbackState.getValue() == null) { + if (queryPlaying || playbackState.getValue() == null) { MediaItem item = mediaController.getCurrentMediaItem(); - if (item != null && item.localConfiguration != null) { - currentUri = item.localConfiguration.uri; + if (item != null) { + try { + currentMsgId = Integer.parseInt(item.mediaId); + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid integer", e); + } + if (item.localConfiguration != null) { + currentUri = item.localConfiguration.uri; + } } } - updateState(currentUri, + updateState( + currentMsgId, + currentUri, status, mediaController.getCurrentPosition(), mediaController.getDuration()); } - private void updateState(Uri audioUri, + private void updateState(int msgId, + Uri audioUri, AudioPlaybackState.PlaybackStatus status, long position, long duration) { playbackState.setValue(new AudioPlaybackState( - audioUri, status, position, duration + msgId, audioUri, status, position, duration )); } @@ -167,7 +187,7 @@ private void updateCurrentAudioState(AudioPlaybackState.PlaybackStatus status, AudioPlaybackState current = playbackState.getValue(); if (current != null) { - updateState(current.getAudioUri(), status, position, duration); + updateState(current.getMsgId(), current.getAudioUri(), status, position, duration); } } diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index b684b87d8..ff924b554 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -37,6 +37,7 @@ public class AudioView extends FrameLayout { private final @NonNull TextView title; private final @NonNull View mask; + private int msgId; private Uri audioUri; private AudioPlaybackViewModel viewModel; private final Observer stateObserver = this::onPlaybackStateChanged; @@ -99,7 +100,7 @@ private void setupControls() { AudioPlaybackState state = viewModel.getPlaybackState().getValue(); - if (state != null && audioUri.equals(state.getAudioUri())) { + if (state != null && msgId == state.getMsgId() && audioUri.equals(state.getAudioUri())) { // Same audio if (state.getStatus() == AudioPlaybackState.PlaybackStatus.PLAYING) { viewModel.pause(); @@ -108,7 +109,8 @@ private void setupControls() { } } else { // Different audio - viewModel.loadAudioAndPlay(audioUri); + // Note: they can be the same *physical* file, but in different messages + viewModel.loadAudioAndPlay(msgId, audioUri); } }); @@ -162,6 +164,7 @@ public void setPlaybackViewModel(AudioPlaybackViewModel viewModel) { public void setAudio(final @NonNull AudioSlide audio, int duration) { + msgId = audio.getDcMsgId(); audioUri = audio.getUri(); playPauseButton.setImageDrawable(playDrawable); @@ -255,7 +258,7 @@ private void onPlaybackStateChanged(AudioPlaybackState state) { if (audioUri == null || state == null) return; // Check if this state is about this message - boolean isThisMessage = audioUri.equals(state.getAudioUri()); + boolean isThisMessage = msgId == state.getMsgId() && audioUri.equals(state.getAudioUri()); if (isThisMessage) { updateUIForPlaybackState(state); diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index e62933017..f6c2f9899 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -49,13 +49,13 @@ import org.thoughtcrime.securesms.WebxdcStoreActivity; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.UriAttachment; -import org.thoughtcrime.securesms.audio.AudioSlidePlayer; -import org.thoughtcrime.securesms.components.audioplay.AudioView; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.RemovableEditableMediaView; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.components.VcardView; import org.thoughtcrime.securesms.components.WebxdcView; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.geolocation.DcLocationManager; @@ -90,7 +90,7 @@ public class AttachmentManager { private RemovableEditableMediaView removableMediaView; private ThumbnailView thumbnail; -// private AudioView audioView; // TODO: is this used? + private AudioView audioView; private DocumentView documentView; private WebxdcView webxdcView; private VcardView vcardView; @@ -114,7 +114,7 @@ private void inflateStub() { View root = attachmentViewStub.get(); this.thumbnail = ViewUtil.findById(root, R.id.attachment_thumbnail); -// this.audioView = ViewUtil.findById(root, R.id.attachment_audio); + this.audioView = ViewUtil.findById(root, R.id.attachment_audio); this.documentView = ViewUtil.findById(root, R.id.attachment_document); this.webxdcView = ViewUtil.findById(root, R.id.attachment_webxdc); this.vcardView = ViewUtil.findById(root, R.id.attachment_vcard); @@ -233,7 +233,8 @@ public ListenableFuture setMedia(@NonNull final GlideRequests glideRequ @NonNull final MediaType mediaType, final int width, final int height, - final int chatId) + final int chatId, + AudioPlaybackViewModel playbackViewModel) { inflateStub(); @@ -283,26 +284,9 @@ protected void onPostExecute(@Nullable final Slide slide) { setAttachmentPresent(true); if (slide.hasAudio()) { -// class SetDurationListener implements AudioSlidePlayer.Listener { -// @Override -// public void onStart() {} -// -// @Override -// public void onStop() {} -// -// @Override -// public void onProgress(AudioSlide slide, double progress, long millis) {} -// -// @Override -// public void onReceivedDuration(int millis) { -// ((AudioView) removableMediaView.getCurrent()).setDuration(millis); -// } -// } -// AudioSlidePlayer audioSlidePlayer = AudioSlidePlayer.createFor(context, (AudioSlide) slide, new SetDurationListener()); -// audioSlidePlayer.requestDuration(); -// -// audioView.setAudio((AudioSlide) slide, 0); -// removableMediaView.display(audioView, false); + audioView.setPlaybackViewModel(playbackViewModel); + audioView.setAudio((AudioSlide) slide, 0); + removableMediaView.display(audioView, false); result.set(true); } else if (slide.isVcard()) { vcardView.setVcard(glideRequests, (VcardSlide)slide, DcHelper.getRpc(context)); From 59139ed2427f886b531cdf47f87df842ed52c894 Mon Sep 17 00:00:00 2001 From: wch423 Date: Wed, 28 Jan 2026 21:25:44 +0100 Subject: [PATCH 07/19] Add support for attachment draft; Distinguish between different messages; Prevent unrelated activities from changing the pending intent --- .../securesms/BindableConversationItem.java | 4 ++- .../securesms/ConversationActivity.java | 28 +++++++++++-------- .../securesms/ConversationAdapter.java | 8 +++++- .../securesms/ConversationFragment.java | 1 + .../securesms/ConversationItem.java | 9 ++++-- .../securesms/ConversationUpdateItem.java | 4 ++- .../audioplay/AudioPlaybackViewModel.java | 5 ++-- .../components/audioplay/AudioView.java | 13 +++++++++ 8 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 744ef8787..33f49a78a 100644 --- a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -7,6 +7,7 @@ import com.b44t.messenger.DcMsg; import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; @@ -19,7 +20,8 @@ void bind(@NonNull DcMsg messageRecord, @NonNull Set batchSelected, @NonNull Recipient recipients, boolean pulseHighlight, - @Nullable AudioPlaybackViewModel playbackViewModel); + @Nullable AudioPlaybackViewModel playbackViewModel, + AudioView.OnActionListener audioPlayPauseListener); DcMsg getMessageRecord(); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 97e0530e1..a0be6dac1 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -94,6 +94,7 @@ import org.thoughtcrime.securesms.components.ScaleStableImageView; import org.thoughtcrime.securesms.components.SendButton; import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; import org.thoughtcrime.securesms.connect.AccountManager; import org.thoughtcrime.securesms.connect.DcEventCenter; @@ -148,14 +149,13 @@ */ @SuppressLint("StaticFieldLeak") public class ConversationActivity extends PassphraseRequiredActionBarActivity - implements ConversationFragment.ConversationFragmentListener, - AttachmentManager.AttachmentListener, - SearchView.OnQueryTextListener, - DcEventCenter.DcEventDelegate, - OnKeyboardShownListener, - InputPanel.Listener, - InputPanel.MediaListener -{ + implements ConversationFragment.ConversationFragmentListener, + AttachmentManager.AttachmentListener, + SearchView.OnQueryTextListener, + DcEventCenter.DcEventDelegate, + OnKeyboardShownListener, + InputPanel.Listener, + InputPanel.MediaListener, AudioView.OnActionListener { private static final String TAG = ConversationActivity.class.getSimpleName(); public static final String ACCOUNT_ID_EXTRA = "account_id"; @@ -289,10 +289,6 @@ private void initializeMediaController() { mediaControllerFuture.addListener(() -> { try { mediaController = mediaControllerFuture.get(); - addActivityContext( - this.getIntent().getExtras(), - this.getClass().getName() - ); playbackViewModel.setMediaController(mediaController); } catch (Exception e) { Log.e(TAG, "Error connecting to audio playback service", e); @@ -1463,6 +1459,14 @@ private void sendSticker(@NonNull Uri uri, String contentType) { // Listeners + @Override + public void onPlayPauseButtonClicked(View view) { + addActivityContext( + this.getIntent().getExtras(), + this.getClass().getName() + ); + } + private class AttachmentTypeListener implements AttachmentTypeSelector.AttachmentClickedListener { @Override public void onClick(int type) { diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java index a5d57ee2a..5a8c66a6c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder; import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; @@ -100,6 +101,7 @@ public class ConversationAdapter private int lastSeenPosition = -1; private long lastSeen = -1; private AudioPlaybackViewModel playbackViewModel; + private AudioView.OnActionListener audioPlayPauseListener; protected static class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(final @NonNull V itemView) { @@ -177,6 +179,10 @@ public void setPlaybackViewModel(AudioPlaybackViewModel playbackViewModel) { this.playbackViewModel = playbackViewModel; } + public void setAudioPlayPauseListener(AudioView.OnActionListener audioPlayPauseListener) { + this.audioPlayPauseListener = audioPlayPauseListener; + } + /** * Returns the position of the message with msgId in the chat list, counted from the top */ @@ -244,7 +250,7 @@ public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { long elapsed = now - pulseHighlightingSince; boolean pulseHighlight = (positionCurrentlyPulseHighlighting == position && elapsed < PULSE_HIGHLIGHT_MILLIS); - holder.getItem().bind(getMsg(position), dcChat, glideRequests, batchSelected, recipient, pulseHighlight, playbackViewModel); + holder.getItem().bind(getMsg(position), dcChat, glideRequests, batchSelected, recipient, pulseHighlight, playbackViewModel, audioPlayPauseListener); } @Override diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index f2f1b2720..ffd293c05 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -304,6 +304,7 @@ private void initializeListAdapter() { AudioPlaybackViewModel playbackViewModel = new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class); adapter.setPlaybackViewModel(playbackViewModel); + adapter.setAudioPlayPauseListener(((ConversationActivity) requireActivity())); if (dateDecoration != null) { list.removeItemDecoration(dateDecoration); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 9a9d2f3da..3adf5441f 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -181,7 +181,8 @@ public void bind(@NonNull DcMsg messageRecord, @NonNull Set batchSelected, @NonNull Recipient recipients, boolean pulseHighlight, - @Nullable AudioPlaybackViewModel playbackViewModel) + @Nullable AudioPlaybackViewModel playbackViewModel, + AudioView.OnActionListener audioPlayPauseListener) { bindPartial(messageRecord, dcChat, batchSelected, pulseHighlight, recipients); this.glideRequests = glideRequests; @@ -204,7 +205,7 @@ public void bind(@NonNull DcMsg messageRecord, setGutterSizes(messageRecord, showSender); setMessageShape(messageRecord); - setMediaAttributes(messageRecord, showSender, playbackViewModel); + setMediaAttributes(messageRecord, showSender, playbackViewModel, audioPlayPauseListener); setBodyText(messageRecord); setBubbleState(messageRecord); setContactPhoto(); @@ -480,7 +481,8 @@ else if (messageRecord.hasHtml()) { private void setMediaAttributes(@NonNull DcMsg messageRecord, boolean showSender, - AudioPlaybackViewModel playbackViewModel) + AudioPlaybackViewModel playbackViewModel, + AudioView.OnActionListener audioPlayPauseListener) { if (hasAudio(messageRecord)) { audioViewStub.get().setVisibility(View.VISIBLE); @@ -495,6 +497,7 @@ private void setMediaAttributes(@NonNull DcMsg messageRecord, int duration = messageRecord.getDuration(); audioViewStub.get().setPlaybackViewModel(playbackViewModel); + audioViewStub.get().setOnActionListener(audioPlayPauseListener); audioViewStub.get().setAudio(new AudioSlide(context, messageRecord), duration); audioViewStub.get().setOnClickListener(passthroughClickListener); audioViewStub.get().setOnLongClickListener(passthroughClickListener); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java index c9d58817d..bf1fe4afa 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java @@ -16,6 +16,7 @@ import org.json.JSONObject; import org.thoughtcrime.securesms.components.DeliveryStatusView; import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.JsonUtils; @@ -63,7 +64,8 @@ public void bind(@NonNull DcMsg messageRecord, @NonNull Set batchSelected, @NonNull Recipient conversationRecipient, boolean pulseUpdate, - @Nullable AudioPlaybackViewModel playbackViewModel) + @Nullable AudioPlaybackViewModel playbackViewModel, + AudioView.OnActionListener audioPlayPauseListener) { bindPartial(messageRecord, dcChat, batchSelected, pulseUpdate, conversationRecipient); setGenericInfoRecord(messageRecord); diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java index fb8796637..852b0820c 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -54,9 +54,10 @@ public void loadAudioAndPlay(int msgId, Uri audioUri) { updateState(msgId, audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0); - // Set media item if we have a different audio. Message ID doesn't matter here. + // Set media item if we have a different audio. if (currentState != null && ( - currentState.getAudioUri() == null || + msgId != currentState.getMsgId() || + currentState.getAudioUri() == null || currentState.getAudioUri() != null && !currentState.getAudioUri().equals(audioUri))) { MediaItem mediaItem = new MediaItem.Builder() .setMediaId(String.valueOf(msgId)) diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index ff924b554..eec20b59b 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -36,6 +36,7 @@ public class AudioView extends FrameLayout { private final @NonNull TextView timestamp; private final @NonNull TextView title; private final @NonNull View mask; + private OnActionListener listener; private int msgId; private Uri audioUri; @@ -112,6 +113,10 @@ private void setupControls() { // Note: they can be the same *physical* file, but in different messages viewModel.loadAudioAndPlay(msgId, audioUri); } + + if (listener != null) { + listener.onPlayPauseButtonClicked(v); + } }); seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @@ -194,6 +199,14 @@ public void setOnLongClickListener(OnLongClickListener listener) { this.playPauseButton.setOnLongClickListener(listener); } + public interface OnActionListener { + void onPlayPauseButtonClicked(View view); + } + + public void setOnActionListener(OnActionListener listener) { + this.listener = listener; + } + public void togglePlay() { playPauseButton.performClick(); } From 34be7aab1749249ffd7f206860c3824678dc9f3e Mon Sep 17 00:00:00 2001 From: wch423 Date: Wed, 28 Jan 2026 21:28:03 +0100 Subject: [PATCH 08/19] Cleanup imports --- CHANGELOG.md | 1 + .../org/thoughtcrime/securesms/AllMediaActivity.java | 3 --- .../thoughtcrime/securesms/AllMediaDocumentsAdapter.java | 4 ++-- .../securesms/AllMediaDocumentsFragment.java | 8 -------- .../org/thoughtcrime/securesms/ConversationActivity.java | 6 ++---- .../org/thoughtcrime/securesms/ConversationFragment.java | 9 --------- .../org/thoughtcrime/securesms/ConversationItem.java | 6 +++--- .../components/audioplay/AudioPlaybackViewModel.java | 6 ------ .../thoughtcrime/securesms/mms/AttachmentManager.java | 2 +- .../securesms/service/AudioPlaybackService.java | 2 -- 10 files changed, 9 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e98854105..f18047039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * Make QR code larger on "Add Second Device" screen * Add indication for blocked contacts in user profile * Show hint for empty contact search results +* Add background playing for voice messages and other audio files * Fix: Show dialog if pasted QR codes are invalid * Fix: Refresh chat list when returning from conversation if selected profile changed * Fix: Update menu when using "select all" in contact selection diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java index 1035267dd..56d2fdeb0 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms; import android.content.ComponentName; -import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.MenuItem; @@ -14,7 +13,6 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentStatePagerAdapter; import androidx.lifecycle.ViewModelProvider; @@ -35,7 +33,6 @@ import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; -import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.ArrayList; diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java index 350c962fa..ede64b580 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java @@ -11,10 +11,10 @@ import com.b44t.messenger.DcMsg; import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; -import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; -import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.WebxdcView; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.DocumentSlide; diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java index 13a42565a..8b0544e14 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java @@ -2,12 +2,10 @@ import static com.b44t.messenger.DcChat.DC_CHAT_NO_CHAT; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; -import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -16,28 +14,22 @@ import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; -import androidx.core.content.ContextCompat; import androidx.lifecycle.ViewModelProvider; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; -import androidx.media3.session.MediaController; -import androidx.media3.session.SessionToken; import androidx.recyclerview.widget.RecyclerView; import com.b44t.messenger.DcContext; import com.b44t.messenger.DcEvent; import com.b44t.messenger.DcMsg; import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; -import com.google.common.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.connect.DcEventCenter; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader; -import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.ViewUtil; import java.util.Set; diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index a0be6dac1..98e488642 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -68,7 +68,6 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.core.view.WindowCompat; -import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; import androidx.media3.session.MediaController; import androidx.media3.session.SessionCommand; @@ -84,6 +83,7 @@ import org.thoughtcrime.securesms.attachments.UriAttachment; import org.thoughtcrime.securesms.audio.AudioRecorder; import org.thoughtcrime.securesms.audio.AudioSlidePlayer; +import org.thoughtcrime.securesms.calls.CallUtil; import org.thoughtcrime.securesms.components.AnimatingToggle; import org.thoughtcrime.securesms.components.AttachmentTypeSelector; import org.thoughtcrime.securesms.components.ComposeText; @@ -117,16 +117,15 @@ import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Prefs; -import org.thoughtcrime.securesms.util.ShareUtil; import org.thoughtcrime.securesms.util.SendRelayedMessageUtil; import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.ShareUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; import org.thoughtcrime.securesms.util.guava.Optional; import org.thoughtcrime.securesms.util.views.ProgressDialog; import org.thoughtcrime.securesms.video.recode.VideoRecoder; -import org.thoughtcrime.securesms.calls.CallUtil; import java.io.File; import java.util.ArrayList; @@ -136,7 +135,6 @@ import chat.delta.rpc.Rpc; import chat.delta.rpc.RpcException; -// TODO: why do we need customize Futures? import chat.delta.util.ListenableFuture; import chat.delta.util.SettableFuture; diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index ffd293c05..3ddc5bc90 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -21,7 +21,6 @@ import android.annotation.SuppressLint; import android.app.Activity; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; @@ -39,15 +38,9 @@ import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.ActionMode; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; -import androidx.media3.session.MediaController; -import androidx.media3.session.SessionCommand; -import androidx.media3.session.SessionToken; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnScrollListener; @@ -57,7 +50,6 @@ import com.b44t.messenger.DcContext; import com.b44t.messenger.DcEvent; import com.b44t.messenger.DcMsg; -import com.google.common.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener; import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; @@ -70,7 +62,6 @@ import org.thoughtcrime.securesms.reactions.ReactionsDetailsFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.relay.EditRelayActivity; -import org.thoughtcrime.securesms.service.AudioPlaybackService; import org.thoughtcrime.securesms.util.AccessibilityUtil; import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 3adf5441f..d0973a2d4 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -41,8 +41,7 @@ import com.b44t.messenger.DcContact; import com.b44t.messenger.DcMsg; -import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; -import org.thoughtcrime.securesms.components.audioplay.AudioView; +import org.thoughtcrime.securesms.calls.CallUtil; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.BorderlessImageView; import org.thoughtcrime.securesms.components.CallItemView; @@ -52,6 +51,8 @@ import org.thoughtcrime.securesms.components.QuoteView; import org.thoughtcrime.securesms.components.VcardView; import org.thoughtcrime.securesms.components.WebxdcView; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; +import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.DocumentSlide; @@ -70,7 +71,6 @@ import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.views.Stub; -import org.thoughtcrime.securesms.calls.CallUtil; import java.util.List; import java.util.Set; diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java index 852b0820c..406de5094 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -13,12 +13,6 @@ import androidx.media3.common.Player; import androidx.media3.session.MediaController; -import com.google.common.util.concurrent.ListenableFuture; - -import org.thoughtcrime.securesms.connect.AccountManager; - -import java.util.concurrent.Future; - public class AudioPlaybackViewModel extends ViewModel { private static final String TAG = AudioPlaybackViewModel.class.getSimpleName(); diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index f6c2f9899..d1e1b54d5 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -49,12 +49,12 @@ import org.thoughtcrime.securesms.WebxdcStoreActivity; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.UriAttachment; -import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.RemovableEditableMediaView; import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.components.VcardView; import org.thoughtcrime.securesms.components.WebxdcView; +import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel; import org.thoughtcrime.securesms.components.audioplay.AudioView; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.database.AttachmentDatabase; diff --git a/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java index 5b592da3d..8cbe1de63 100644 --- a/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java +++ b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java @@ -3,7 +3,6 @@ import android.app.PendingIntent; import android.content.Intent; import android.os.Bundle; -import android.provider.MediaStore; import android.util.Log; import androidx.annotation.NonNull; @@ -22,7 +21,6 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import org.thoughtcrime.securesms.AllMediaDocumentsFragment; import org.thoughtcrime.securesms.ConversationListActivity; public class AudioPlaybackService extends MediaSessionService { From 13374df709ae7df9ece43a22043014b31becadf6 Mon Sep 17 00:00:00 2001 From: wch423 Date: Tue, 3 Feb 2026 17:41:31 +0100 Subject: [PATCH 09/19] Bug fixes and minor changes --- build.gradle | 2 +- .../securesms/ConversationActivity.java | 2 - .../securesms/audio/AudioSlidePlayer.java | 357 ------------------ .../components/audioplay/AudioView.java | 16 +- 4 files changed, 10 insertions(+), 367 deletions(-) delete mode 100644 src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java diff --git a/build.gradle b/build.gradle index 27216f056..95c90ef4b 100644 --- a/build.gradle +++ b/build.gradle @@ -173,7 +173,7 @@ dependencies { implementation 'androidx.work:work-runtime:2.9.1' implementation 'androidx.emoji2:emoji2-emojipicker:1.5.0' implementation 'com.google.guava:guava:31.1-android' - implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // FIXME: I am keeping the exoplayer dependencies for Video, but we shall migrate them at some point + implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // FIXME: exoplayer dependencies kept for Video, but we shall migrate them at some point implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' implementation "androidx.media3:media3-exoplayer:$media3_version" implementation "androidx.media3:media3-session:$media3_version" diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 98e488642..b009795d7 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -82,7 +82,6 @@ import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.UriAttachment; import org.thoughtcrime.securesms.audio.AudioRecorder; -import org.thoughtcrime.securesms.audio.AudioSlidePlayer; import org.thoughtcrime.securesms.calls.CallUtil; import org.thoughtcrime.securesms.components.AnimatingToggle; import org.thoughtcrime.securesms.components.AttachmentTypeSelector; @@ -379,7 +378,6 @@ protected void onPause() { DcHelper.getNotificationCenter(this).clearVisibleChat(); if (isFinishing()) overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_right); inputPanel.onPause(); - AudioSlidePlayer.stopAll(); } @Override diff --git a/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java deleted file mode 100644 index fe461f949..000000000 --- a/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ /dev/null @@ -1,357 +0,0 @@ -package org.thoughtcrime.securesms.audio; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Handler; -import android.os.Message; -import android.util.Log; -import android.util.Pair; -import android.view.WindowManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.DefaultLoadControl; -import com.google.android.exoplayer2.DefaultRenderersFactory; -import com.google.android.exoplayer2.LoadControl; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackException; -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.MediaSource; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; - -import org.thoughtcrime.securesms.connect.DcHelper; -import org.thoughtcrime.securesms.mms.AudioSlide; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.guava.Optional; -import org.thoughtcrime.securesms.video.exo.AttachmentDataSourceFactory; - -import java.io.IOException; -import java.lang.ref.WeakReference; - -@Deprecated -public class AudioSlidePlayer { - - private static final String TAG = AudioSlidePlayer.class.getSimpleName(); - - private static @NonNull Optional playing = Optional.absent(); - - private final @NonNull Context context; - private final @NonNull AudioSlide slide; - private final @NonNull Handler progressEventHandler; - - private @NonNull WeakReference listener; - private @Nullable SimpleExoPlayer mediaPlayer; - private @Nullable SimpleExoPlayer durationCalculator; - - 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); - } - - public void requestDuration() { - try { - LoadControl loadControl = new DefaultLoadControl.Builder().setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE).build(); - durationCalculator = new SimpleExoPlayer.Builder(context, new DefaultRenderersFactory(context)) - .setTrackSelector(new DefaultTrackSelector(context)) - .setLoadControl(loadControl) - .build(); - durationCalculator.setPlayWhenReady(false); - durationCalculator.addListener(new Player.Listener() { - @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (playbackState == Player.STATE_READY) { - Util.runOnMain(() -> { - synchronized (AudioSlidePlayer.this) { - if (durationCalculator == null) return; - Log.d(TAG, "request duration " + durationCalculator.getDuration()); - getListener().onReceivedDuration(Long.valueOf(durationCalculator.getDuration()).intValue()); - durationCalculator.release(); - durationCalculator.removeListener(this); - durationCalculator = null; - } - }); - } - } - }); - durationCalculator.prepare(createMediaSource(slide.getUri())); - } catch (Exception e) { - Log.w(TAG, e); - getListener().onReceivedDuration(0); - } - } - - 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).build(); - this.mediaPlayer = new SimpleExoPlayer.Builder(context, new DefaultRenderersFactory(context)) - .setTrackSelector(new DefaultTrackSelector(context)) - .setLoadControl(loadControl) - .build(); - - mediaPlayer.prepare(createMediaSource(slide.getUri())); - mediaPlayer.setPlayWhenReady(true); - mediaPlayer.setAudioAttributes(new AudioAttributes.Builder() - .setContentType(earpiece ? C.AUDIO_CONTENT_TYPE_SPEECH : C.AUDIO_CONTENT_TYPE_MUSIC) - .setUsage(earpiece ? C.USAGE_VOICE_COMMUNICATION : C.USAGE_MEDIA) - .build(), false); - mediaPlayer.addListener(new Player.Listener() { - - 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; - Log.d(TAG, "DURATION: " + mediaPlayer.getDuration()); - - if (started) { - Log.d(TAG, "Already started. Ignoring."); - return; - } - - started = true; - - if (progress > 0) { - mediaPlayer.seekTo((long) (mediaPlayer.getDuration() * progress)); - } - - setPlaying(AudioSlidePlayer.this); - } - - keepScreenOn(true); - notifyOnStart(); - progressEventHandler.sendEmptyMessage(0); - break; - - case Player.STATE_ENDED: - Log.i(TAG, "onComplete"); - synchronized (AudioSlidePlayer.this) { - getListener().onReceivedDuration(Long.valueOf(mediaPlayer.getDuration()).intValue()); - mediaPlayer.release(); - mediaPlayer = null; - } - - keepScreenOn(false); - notifyOnStop(); - progressEventHandler.removeMessages(0); - } - } - - @Override - public void onPlayerError(PlaybackException error) { - Log.w(TAG, "MediaPlayer Error: " + error); - - synchronized (AudioSlidePlayer.this) { - mediaPlayer.release(); - mediaPlayer = null; - } - - notifyOnStop(); - progressEventHandler.removeMessages(0); - - // Failed to play media file, maybe another app can handle it - int msgId = getAudioSlide().getDcMsgId(); - DcHelper.openForViewOrShare(context, msgId, Intent.ACTION_VIEW); - } - }); - } - - private MediaSource createMediaSource(@NonNull Uri uri) { - DefaultDataSourceFactory defaultDataSourceFactory = new DefaultDataSourceFactory(context, "GenericUserAgent", null); - AttachmentDataSourceFactory attachmentDataSourceFactory = new AttachmentDataSourceFactory(defaultDataSourceFactory); - ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true); - - return new ProgressiveMediaSource.Factory(attachmentDataSourceFactory, extractorsFactory) - .createMediaSource(MediaItem.fromUri(uri)); - } - - public synchronized void stop() { - Log.i(TAG, "Stop called!"); - - keepScreenOn(false); - removePlaying(this); - - if (this.mediaPlayer != null) { - this.mediaPlayer.stop(); - this.mediaPlayer.release(); - } - - this.mediaPlayer = null; - } - - public static void stopAll() { - if (playing.isPresent()) { - synchronized (AudioSlidePlayer.class) { - if (playing.isPresent()) { - playing.get().stop(); - } - } - } - } - - public void setListener(@NonNull Listener listener) { - this.listener = new WeakReference<>(listener); - - if (this.mediaPlayer != null && this.mediaPlayer.getPlaybackState() == Player.STATE_READY) { - notifyOnStart(); - } - } - - public @NonNull AudioSlide getAudioSlide() { - return slide; - } - - - private Pair getProgress() { - if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) { - return new Pair<>(0D, 0); - } else { - return new Pair<>((double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(), - (int) mediaPlayer.getCurrentPosition()); - } - } - - private void notifyOnStart() { - Util.runOnMain(new Runnable() { - @Override - public void run() { - getListener().onStart(); - } - }); - } - - private void notifyOnStop() { - Util.runOnMain(new Runnable() { - @Override - public void run() { - getListener().onStop(); - } - }); - } - - private void notifyOnProgress(final double progress, final long millis) { - Util.runOnMain(new Runnable() { - @Override - public void run() { - getListener().onProgress(slide, 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(AudioSlide slide, double progress, long millis) {} - @Override - public void onReceivedDuration(int millis) {} - }; - } - - public void keepScreenOn(boolean keepOn) { - if (context instanceof Activity) { - if (keepOn) { - ((Activity) context).getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } else { - ((Activity) context).getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - } - } - - 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(); - } - } - - public interface Listener { - void onStart(); - void onStop(); - void onProgress(AudioSlide slide, double progress, long millis); - void onReceivedDuration(int millis); - } - - private static class ProgressEventHandler extends Handler { - - private final WeakReference playerReference; - - private ProgressEventHandler(@NonNull AudioSlidePlayer player) { - this.playerReference = new WeakReference<>(player); - } - - @Override - public void handleMessage(@NonNull Message msg) { - AudioSlidePlayer player = playerReference.get(); - - if (player == null || player.mediaPlayer == null || !isPlayerActive(player.mediaPlayer)) { - return; - } - - Pair progress = player.getProgress(); - player.notifyOnProgress(progress.first, progress.second); - sendEmptyMessageDelayed(0, 50); - } - - private boolean isPlayerActive(@NonNull SimpleExoPlayer player) { - return player.getPlaybackState() == Player.STATE_READY || player.getPlaybackState() == Player.STATE_BUFFERING; - } - } -} diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index eec20b59b..e7748b4b3 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -32,6 +32,7 @@ public class AudioView extends FrameLayout { private final AnimatedVectorDrawableCompat pauseToPlayDrawable; private final Drawable playDrawable; private final Drawable pauseDrawable; + private final Animatable2Compat.AnimationCallback animationCallback; private final @NonNull SeekBar seekBar; private final @NonNull TextView timestamp; private final @NonNull TextView title; @@ -72,19 +73,13 @@ public AudioView(Context context, AttributeSet attrs, int defStyleAttr) { this.playDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.play_icon); this.pauseDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.pause_icon); - Animatable2Compat.AnimationCallback animationCallback = new Animatable2Compat.AnimationCallback() { + this.animationCallback = new Animatable2Compat.AnimationCallback() { @Override public void onAnimationEnd(Drawable drawable) { Drawable endState = isPlaying ? pauseDrawable : playDrawable; playPauseButton.setImageDrawable(endState); } }; - if (playToPauseDrawable != null) { - playToPauseDrawable.registerAnimationCallback(animationCallback); - } - if (pauseToPlayDrawable != null) { - pauseToPlayDrawable.registerAnimationCallback(animationCallback); - } } @Override @@ -138,6 +133,13 @@ public void onStopTrackingTouch(SeekBar seekBar) { viewModel.seekTo(seekBar.getProgress()); } }); + + if (playToPauseDrawable != null) { + playToPauseDrawable.registerAnimationCallback(animationCallback); + } + if (pauseToPlayDrawable != null) { + pauseToPlayDrawable.registerAnimationCallback(animationCallback); + } } @Override From 10acb07f82111066f902272cc4288adbe6448b84 Mon Sep 17 00:00:00 2001 From: wch423 Date: Thu, 29 Jan 2026 18:07:24 +0100 Subject: [PATCH 10/19] Make device messages and subscribed channels edge to edge --- .../java/org/thoughtcrime/securesms/ConversationActivity.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index b11340984..473c7b9d3 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -995,6 +995,10 @@ private void initializeResources() { if(chatId == DcChat.DC_CHAT_NO_CHAT) throw new IllegalStateException("can't display a conversation for no chat."); dcChat = DcHelper.getContext(context).getChat(chatId); + if (dcChat.isDeviceTalk() || + isMultiUser() && dcChat.isInBroadcast()) { + ViewUtil.applyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); + } recipient = new Recipient(this, dcChat); glideRequests = GlideApp.with(this); From 93f12e7367901e09c4d003700e2b01c22516b591 Mon Sep 17 00:00:00 2001 From: wch423 Date: Thu, 29 Jan 2026 21:43:11 +0100 Subject: [PATCH 11/19] Make message list respect bottom bar height --- .../securesms/ConversationActivity.java | 5 +++-- .../securesms/ConversationFragment.java | 20 ++++++++++++++++++- src/main/res/layout/conversation_fragment.xml | 1 + 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 473c7b9d3..015dfdf02 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -995,9 +995,10 @@ private void initializeResources() { if(chatId == DcChat.DC_CHAT_NO_CHAT) throw new IllegalStateException("can't display a conversation for no chat."); dcChat = DcHelper.getContext(context).getChat(chatId); - if (dcChat.isDeviceTalk() || - isMultiUser() && dcChat.isInBroadcast()) { + if (dcChat.isDeviceTalk() || dcChat.isInBroadcast()) { ViewUtil.applyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); + ViewUtil.applyWindowInsets(findViewById(R.id.fragment_content), false, true, false, true); + fragment.hideBottomDivider(); } recipient = new Recipient(this, dcChat); glideRequests = GlideApp.with(this); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index 30832749d..685d8e8b2 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -100,6 +100,7 @@ public class ConversationFragment extends MessageSelectorFragment private StickyHeaderDecoration dateDecoration; private View scrollToBottomButton; private View floatingLocationButton; + private View bottomDivider; private AddReactionView addReactionView; private TextView noMessageTextView; private Timer reloadTimer; @@ -107,8 +108,9 @@ public class ConversationFragment extends MessageSelectorFragment public boolean isPaused; private Debouncer markseenDebouncer; private Rpc rpc; + private boolean pendingHideBottomDivider; - @Override + @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); rpc = DcHelper.getRpc(getContext()); @@ -140,6 +142,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, floatingLocationButton = ViewUtil.findById(view, R.id.floating_location_button); addReactionView = ViewUtil.findById(view, R.id.add_reaction_view); noMessageTextView = ViewUtil.findById(view, R.id.no_messages_text_view); + bottomDivider = ViewUtil.findById(view, R.id.bottom_divider); scrollToBottomButton.setOnClickListener(v -> scrollToBottom()); @@ -158,6 +161,11 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, // with hardware layers, drawing may result in errors as "OpenGLRenderer: Path too large to be rendered into a texture" list.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + if (pendingHideBottomDivider) { + bottomDivider.setVisibility(View.GONE); + pendingHideBottomDivider = false; + } + return view; } @@ -196,6 +204,16 @@ else if(!dcChat.isEncrypted()) { } } + public void hideBottomDivider() { + if (bottomDivider != null) { + bottomDivider.setVisibility(View.GONE); + pendingHideBottomDivider = false; + } + else { + pendingHideBottomDivider = true; + } + } + @Override public void onDestroy() { DcHelper.getEventCenter(getContext()).removeObservers(this); diff --git a/src/main/res/layout/conversation_fragment.xml b/src/main/res/layout/conversation_fragment.xml index c42125043..3e54caac7 100644 --- a/src/main/res/layout/conversation_fragment.xml +++ b/src/main/res/layout/conversation_fragment.xml @@ -27,6 +27,7 @@ /> Date: Tue, 3 Feb 2026 19:38:55 +0100 Subject: [PATCH 12/19] Allow list scrolling to extend to edge --- .../securesms/ConversationActivity.java | 5 ++--- .../securesms/ConversationFragment.java | 16 +++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 015dfdf02..f27917989 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -995,10 +995,9 @@ private void initializeResources() { if(chatId == DcChat.DC_CHAT_NO_CHAT) throw new IllegalStateException("can't display a conversation for no chat."); dcChat = DcHelper.getContext(context).getChat(chatId); - if (dcChat.isDeviceTalk() || dcChat.isInBroadcast()) { + if (!dcChat.canSend()) { ViewUtil.applyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); - ViewUtil.applyWindowInsets(findViewById(R.id.fragment_content), false, true, false, true); - fragment.hideBottomDivider(); + fragment.handleAdjustBottomLayout(); } recipient = new Recipient(this, dcChat); glideRequests = GlideApp.with(this); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index 685d8e8b2..b8fe5cdfb 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -108,9 +108,9 @@ public class ConversationFragment extends MessageSelectorFragment public boolean isPaused; private Debouncer markseenDebouncer; private Rpc rpc; - private boolean pendingHideBottomDivider; + private boolean pendingAdjustBottomLayout; - @Override + @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); rpc = DcHelper.getRpc(getContext()); @@ -161,9 +161,10 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, // with hardware layers, drawing may result in errors as "OpenGLRenderer: Path too large to be rendered into a texture" list.setLayerType(View.LAYER_TYPE_SOFTWARE, null); - if (pendingHideBottomDivider) { + if (pendingAdjustBottomLayout) { bottomDivider.setVisibility(View.GONE); - pendingHideBottomDivider = false; + ViewUtil.applyWindowInsets(list, false, true, false, true); + pendingAdjustBottomLayout = false; } return view; @@ -204,13 +205,14 @@ else if(!dcChat.isEncrypted()) { } } - public void hideBottomDivider() { + public void handleAdjustBottomLayout() { if (bottomDivider != null) { bottomDivider.setVisibility(View.GONE); - pendingHideBottomDivider = false; + ViewUtil.applyWindowInsets(list, false, true, false, true); + pendingAdjustBottomLayout = false; } else { - pendingHideBottomDivider = true; + pendingAdjustBottomLayout = true; } } From b66bc1f8637105a046151cc87f063749453c83a1 Mon Sep 17 00:00:00 2001 From: wch423 Date: Wed, 4 Feb 2026 18:04:34 +0100 Subject: [PATCH 13/19] Handle specific case when leaving group; also move floating button to avoid overaapping with system bar --- .../securesms/ConversationActivity.java | 8 +-- .../securesms/ConversationFragment.java | 34 ++++++++--- .../securesms/WebViewActivity.java | 2 +- .../thoughtcrime/securesms/util/ViewUtil.java | 59 +++++++++++++++---- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index f27917989..e6cb30b9d 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -995,10 +995,6 @@ private void initializeResources() { if(chatId == DcChat.DC_CHAT_NO_CHAT) throw new IllegalStateException("can't display a conversation for no chat."); dcChat = DcHelper.getContext(context).getChat(chatId); - if (!dcChat.canSend()) { - ViewUtil.applyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); - fragment.handleAdjustBottomLayout(); - } recipient = new Recipient(this, dcChat); glideRequests = GlideApp.with(this); @@ -1010,10 +1006,14 @@ private void setComposePanelVisibility() { if (dcChat.canSend()) { composePanel.setVisibility(View.VISIBLE); attachmentManager.setHidden(false); + ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, true); + fragment.handleRemoveBottomInsets(); } else { composePanel.setVisibility(View.GONE); attachmentManager.setHidden(true); hideSoftKeyboard(); + ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); + fragment.handleAddBottomInsets(); } } diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index b8fe5cdfb..1276f7a30 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -108,7 +108,8 @@ public class ConversationFragment extends MessageSelectorFragment public boolean isPaused; private Debouncer markseenDebouncer; private Rpc rpc; - private boolean pendingAdjustBottomLayout; + private boolean pendingAddBottomInsets; + private boolean pendingRemoveBottomInsets; @Override public void onCreate(Bundle icicle) { @@ -161,10 +162,18 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, // with hardware layers, drawing may result in errors as "OpenGLRenderer: Path too large to be rendered into a texture" list.setLayerType(View.LAYER_TYPE_SOFTWARE, null); - if (pendingAdjustBottomLayout) { + if (pendingAddBottomInsets) { bottomDivider.setVisibility(View.GONE); ViewUtil.applyWindowInsets(list, false, true, false, true); - pendingAdjustBottomLayout = false; + ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, true); + pendingAddBottomInsets = false; + } + + if (pendingRemoveBottomInsets) { + bottomDivider.setVisibility(View.VISIBLE); + ViewUtil.applyWindowInsets(list, false, true, false, false); + ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, false); + pendingRemoveBottomInsets = false; } return view; @@ -205,14 +214,25 @@ else if(!dcChat.isEncrypted()) { } } - public void handleAdjustBottomLayout() { + public void handleAddBottomInsets() { if (bottomDivider != null) { bottomDivider.setVisibility(View.GONE); ViewUtil.applyWindowInsets(list, false, true, false, true); - pendingAdjustBottomLayout = false; + ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, true); + pendingAddBottomInsets = false; + } else { + pendingAddBottomInsets = true; } - else { - pendingAdjustBottomLayout = true; + } + + public void handleRemoveBottomInsets() { + if (bottomDivider != null) { + bottomDivider.setVisibility(View.VISIBLE); + ViewUtil.applyWindowInsets(list, false, true, false, false); + ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, false); + pendingRemoveBottomInsets = false; + } else { + pendingRemoveBottomInsets = true; } } diff --git a/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java b/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java index 87094a35d..f6d1ec8fe 100644 --- a/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/WebViewActivity.java @@ -77,7 +77,7 @@ protected void onCreate(Bundle state, boolean ready) { findViewById(R.id.status_bar_background).setBackgroundResource(R.drawable.search_toolbar_shadow); } else { // add padding to avoid content hidden behind system bars - ViewUtil.applyWindowInsets(findViewById(R.id.content_container), true, true, true, true, true); + ViewUtil.applyWindowInsets(findViewById(R.id.content_container), true, true, true, true, true, false); } webView.setWebViewClient(new WebViewClient() { diff --git a/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java index 91c4d3a6b..c90480836 100644 --- a/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java @@ -323,7 +323,15 @@ private static Insets getCombinedInsets(@NonNull WindowInsetsCompat windowInsets * @param view The view to apply insets to */ public static void applyWindowInsetsAsMargin(@NonNull View view) { - applyWindowInsetsAsMargin(view, true, true, true, true); + applyWindowInsetsAsMargin(view, true, true, true, true, false); + } + + public static void applyWindowInsetsAsMargin(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom) { + applyWindowInsetsAsMargin(view, left, top, right, bottom, false); + } + + public static void forceApplyWindowInsetsAsMargin(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom) { + applyWindowInsetsAsMargin(view, left, top, right, bottom, true); } /** @@ -337,8 +345,9 @@ public static void applyWindowInsetsAsMargin(@NonNull View view) { * @param top Whether to apply top inset * @param right Whether to apply right inset * @param bottom Whether to apply bottom inset + * @param forceDispatch Whether to force application of insets */ - public static void applyWindowInsetsAsMargin(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom) { + public static void applyWindowInsetsAsMargin(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom, boolean forceDispatch) { // Only enable on API 30+ where WindowInsets APIs work correctly if (!isEdgeToEdgeSupported()) return; @@ -371,10 +380,10 @@ public static void applyWindowInsetsAsMargin(@NonNull View view, boolean left, b ViewGroup.LayoutParams layoutParams = v.getLayoutParams(); if (layoutParams instanceof ViewGroup.MarginLayoutParams) { ViewGroup.MarginLayoutParams marginParams = (ViewGroup.MarginLayoutParams) layoutParams; - marginParams.leftMargin = baseMarginLeft + insets.left; - marginParams.topMargin = baseMarginTop + insets.top; - marginParams.rightMargin = baseMarginRight + insets.right; - marginParams.bottomMargin = baseMarginBottom + insets.bottom; + marginParams.leftMargin = left ? baseMarginLeft + insets.left : baseMarginLeft; + marginParams.topMargin = top ? baseMarginTop + insets.top : baseMarginTop; + marginParams.rightMargin = right ? baseMarginRight + insets.right : baseMarginRight; + marginParams.bottomMargin = bottom ? baseMarginBottom + insets.bottom : baseMarginBottom; v.setLayoutParams(marginParams); } @@ -383,7 +392,14 @@ public static void applyWindowInsetsAsMargin(@NonNull View view, boolean left, b // Request the initial insets to be dispatched if the view is attached if (view.isAttachedToWindow()) { - ViewCompat.requestApplyInsets(view); + if (forceDispatch) { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(view); + if (insets != null) { + ViewCompat.dispatchApplyWindowInsets(view, insets); + } + } else { + ViewCompat.requestApplyInsets(view); + } } } @@ -395,7 +411,7 @@ public static void applyWindowInsetsAsMargin(@NonNull View view, boolean left, b * @param view The view to apply insets to */ public static void applyWindowInsets(@NonNull View view) { - applyWindowInsets(view, true, true, true, true, false); + applyWindowInsets(view, true, true, true, true, false, false); } /** @@ -410,7 +426,20 @@ public static void applyWindowInsets(@NonNull View view) { * @param bottom Whether to apply bottom inset */ public static void applyWindowInsets(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom) { - applyWindowInsets(view, left, top, right, bottom, false); + applyWindowInsets(view, left, top, right, bottom, false, false); + } + + /** + * Force applying window insets to a view by adding padding to avoid drawing elements behind system bars. + * + * @param view The view to apply insets to + * @param left Whether to apply left inset + * @param top Whether to apply top inset + * @param right Whether to apply right inset + * @param bottom Whether to apply bottom inset + */ + public static void forceApplyWindowInsets(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom) { + applyWindowInsets(view, left, top, right, bottom, false, true); } /** @@ -425,8 +454,9 @@ public static void applyWindowInsets(@NonNull View view, boolean left, boolean t * @param right Whether to apply right inset * @param bottom Whether to apply bottom inset * @param consumeImeInsets Whether to consume IME insets so they don't propagate to child views + * @param forceDispatch Force application of Insets, regardless if system think it shall dispatch */ - public static void applyWindowInsets(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom, boolean consumeImeInsets) { + public static void applyWindowInsets(@NonNull View view, boolean left, boolean top, boolean right, boolean bottom, boolean consumeImeInsets, boolean forceDispatch) { // Only enable on API 30+ where WindowInsets APIs work correctly if (!isEdgeToEdgeSupported()) return; @@ -469,7 +499,14 @@ public static void applyWindowInsets(@NonNull View view, boolean left, boolean t // Request the initial insets to be dispatched if the view is attached if (view.isAttachedToWindow()) { - ViewCompat.requestApplyInsets(view); + if (forceDispatch) { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(view); + if (insets != null) { + ViewCompat.dispatchApplyWindowInsets(view, insets); + } + } else { + ViewCompat.requestApplyInsets(view); + } } } From 71ed33346856eb3bcc3af3509189dfb13ba9b3ad Mon Sep 17 00:00:00 2001 From: wch423 Date: Wed, 4 Feb 2026 22:49:18 +0100 Subject: [PATCH 14/19] Stop playback when attachment preview is removed; Stop playback when message containing audio is removed --- build.gradle | 4 ++ .../securesms/AllMediaDocumentsFragment.java | 3 +- .../securesms/ConversationFragment.java | 14 ++++- .../securesms/MessageSelectorFragment.java | 26 +++++--- .../RemovableEditableMediaView.java | 22 ++++++- .../audioplay/AudioPlaybackViewModel.java | 61 +++++++++++++------ .../components/audioplay/AudioView.java | 16 +++-- .../securesms/mms/AttachmentManager.java | 5 +- 8 files changed, 113 insertions(+), 38 deletions(-) diff --git a/build.gradle b/build.gradle index 95c90ef4b..6436c4ad9 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ android { compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true } packagingOptions { jniLibs { @@ -152,7 +153,10 @@ android { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + def media3_version = "1.8.0" // 1.9.0 need minSdkVersion 23 + implementation 'androidx.concurrent:concurrent-futures:1.3.0' implementation 'androidx.sharetarget:sharetarget:1.2.0' implementation 'androidx.webkit:webkit:1.14.0' diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java index 8b0544e14..439400c40 100644 --- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java @@ -243,12 +243,13 @@ public boolean onPrepareActionMode(ActionMode mode, Menu menu) { @Override public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { int itemId = menuItem.getItemId(); + AudioPlaybackViewModel playbackViewModel = new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class); if (itemId == R.id.details) { handleDisplayDetails(getSelectedMessageRecord(getListAdapter().getSelectedMedia())); mode.finish(); return true; } else if (itemId == R.id.delete) { - handleDeleteMessages(chatId, getListAdapter().getSelectedMedia()); + handleDeleteMessages(chatId, getListAdapter().getSelectedMedia(), playbackViewModel::stopByIds, playbackViewModel::stopByIds); mode.finish(); return true; } else if (itemId == R.id.share) { diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index ff834a0d1..101bf6140 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -394,7 +394,14 @@ static boolean canEditMsg(DcMsg dcMsg) { } public void handleClearChat() { - handleDeleteMessages((int) chatId, getListAdapter().getMessageIds()); + AudioPlaybackViewModel playbackViewModel = + new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class); + + handleDeleteMessages( + (int) chatId, + getListAdapter().getMessageIds(), + playbackViewModel::stopByIds, + playbackViewModel::stopByIds); } private ConversationAdapter getListAdapter() { @@ -924,12 +931,15 @@ public void onDestroyActionMode(ActionMode mode) { public boolean onActionItemClicked(ActionMode mode, MenuItem item) { hideAddReactionView(); int itemId = item.getItemId(); + AudioPlaybackViewModel playbackViewModel = + new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class); + if (itemId == R.id.menu_context_copy) { handleCopyMessage(getListAdapter().getSelectedItems()); actionMode.finish(); return true; } else if (itemId == R.id.menu_context_delete_message) { - handleDeleteMessages((int) chatId, getListAdapter().getSelectedItems()); + handleDeleteMessages((int) chatId, getListAdapter().getSelectedItems(), playbackViewModel::stopByIds, playbackViewModel::stopByIds); return true; } else if (itemId == R.id.menu_context_share) { DcHelper.openForViewOrShare(getContext(), getSelectedMessageRecord(getListAdapter().getSelectedItems()).getId(), Intent.ACTION_SEND); diff --git a/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java b/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java index 8320b05e0..abb72a3b4 100644 --- a/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java @@ -2,6 +2,7 @@ import android.Manifest; import android.app.Activity; +import android.content.DialogInterface; import android.content.Intent; import android.net.Uri; import android.os.AsyncTask; @@ -26,6 +27,7 @@ import org.thoughtcrime.securesms.util.Util; import java.util.Set; +import java.util.function.Consumer; public abstract class MessageSelectorFragment extends Fragment @@ -57,10 +59,14 @@ protected void handleDisplayDetails(DcMsg dcMsg) { } protected void handleDeleteMessages(int chatId, final Set messageRecords) { - handleDeleteMessages(chatId, DcMsg.msgSetToIds(messageRecords)); + handleDeleteMessages(chatId, DcMsg.msgSetToIds(messageRecords), null, null); } - protected void handleDeleteMessages(int chatId, final int[] messageIds) { + protected void handleDeleteMessages(int chatId, final Set messageRecords, Consumer deleteForMeListenerExtra, Consumer deleteForAllListenerExtra) { + handleDeleteMessages(chatId, DcMsg.msgSetToIds(messageRecords), deleteForMeListenerExtra, deleteForAllListenerExtra); + } + + protected void handleDeleteMessages(int chatId, final int[] messageIds, Consumer deleteForMeListenerExtra, Consumer deleteForAllListenerExtra) { DcContext dcContext = DcHelper.getContext(getContext()); DcChat dcChat = dcContext.getChat(chatId); boolean canDeleteForAll = true; @@ -79,20 +85,24 @@ protected void handleDeleteMessages(int chatId, final int[] messageIds) { String text = getActivity().getResources().getQuantityString(R.plurals.ask_delete_messages, messageIds.length, messageIds.length); int positiveBtnLabel = dcChat.isSelfTalk() ? R.string.delete : R.string.delete_for_me; + DialogInterface.OnClickListener deleteForMeListener = (d, which) -> { + Util.runOnAnyBackgroundThread(() -> dcContext.deleteMsgs(messageIds)); + if (actionMode != null) actionMode.finish(); + if (deleteForMeListenerExtra != null) deleteForMeListenerExtra.accept(messageIds); + }; AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()) .setMessage(text) .setCancelable(true) .setNeutralButton(android.R.string.cancel, null) - .setPositiveButton(positiveBtnLabel, (d, which) -> { - Util.runOnAnyBackgroundThread(() -> dcContext.deleteMsgs(messageIds)); - if (actionMode != null) actionMode.finish(); - }); + .setPositiveButton(positiveBtnLabel, deleteForMeListener); if(canDeleteForAll) { - builder.setNegativeButton(R.string.delete_for_everyone, (d, which) -> { + DialogInterface.OnClickListener deleteForAllListener = (d, which) -> { Util.runOnAnyBackgroundThread(() -> dcContext.sendDeleteRequest(messageIds)); if (actionMode != null) actionMode.finish(); - }); + if (deleteForAllListenerExtra != null) deleteForAllListenerExtra.accept(messageIds); + }; + builder.setNegativeButton(R.string.delete_for_everyone, deleteForAllListener); AlertDialog dialog = builder.show(); Util.redButton(dialog, AlertDialog.BUTTON_NEGATIVE); Util.redPositiveButton(dialog); diff --git a/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java b/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java index 20a88178a..870ea4216 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/RemovableEditableMediaView.java @@ -11,6 +11,9 @@ import org.thoughtcrime.securesms.R; +import java.util.ArrayList; +import java.util.List; + public class RemovableEditableMediaView extends FrameLayout { private final @NonNull ImageView remove; @@ -19,6 +22,7 @@ public class RemovableEditableMediaView extends FrameLayout { private final int removeSize; private @Nullable View current; + private final List removeClickListeners = new ArrayList<>(); public RemovableEditableMediaView(Context context) { this(context, null); @@ -72,8 +76,22 @@ public View getCurrent() { return current; } - public void setRemoveClickListener(View.OnClickListener listener) { - this.remove.setOnClickListener(listener); + public void addRemoveClickListener(View.OnClickListener listener) { + removeClickListeners.add(listener); + updateRemoveClickListener(); + } + + public void removeRemoveClickListener(View.OnClickListener listener) { + removeClickListeners.remove(listener); + updateRemoveClickListener(); + } + + private void updateRemoveClickListener() { + this.remove.setOnClickListener(v -> { + for (OnClickListener listener : removeClickListeners) { + listener.onClick(v); + } + }); } public void setEditClickListener(View.OnClickListener listener) { diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java index 406de5094..9d6588467 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -44,15 +44,10 @@ public void setMediaController(@Nullable MediaController controller) { public void loadAudioAndPlay(int msgId, Uri audioUri) { if (mediaController == null) return; - AudioPlaybackState currentState = playbackState.getValue(); - - updateState(msgId, audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0); - // Set media item if we have a different audio. - if (currentState != null && ( - msgId != currentState.getMsgId() || - currentState.getAudioUri() == null || - currentState.getAudioUri() != null && !currentState.getAudioUri().equals(audioUri))) { + if (isDifferentAudio(msgId, audioUri)) { + updateState(msgId, audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0); + MediaItem mediaItem = new MediaItem.Builder() .setMediaId(String.valueOf(msgId)) .setUri(audioUri) @@ -61,34 +56,60 @@ public void loadAudioAndPlay(int msgId, Uri audioUri) { mediaController.prepare(); } - play(); + play(msgId, audioUri); } + private boolean isSameAudio(int msgId, Uri audioUri) { + return !isDifferentAudio(msgId, audioUri); + } + + private boolean isDifferentAudio(int msgId, Uri audioUri) { + AudioPlaybackState currentState = playbackState.getValue(); - public void pause() { - if (mediaController != null) { + return currentState != null && ( + msgId != currentState.getMsgId() || + currentState.getAudioUri() == null || + currentState.getAudioUri() != null && !currentState.getAudioUri().equals(audioUri)); + } + + public void pause(int msgId, Uri audioUri) { + if (mediaController != null && isSameAudio(msgId, audioUri)) { mediaController.pause(); } } - public void play() { - if (mediaController != null) { + public void play(int msgId, Uri audioUri) { + if (mediaController != null && isSameAudio(msgId, audioUri)) { mediaController.play(); } } - public void seekTo(long position) { - if (mediaController != null) { + public void seekTo(long position, int msgId, Uri audioUri) { + if (mediaController != null && isSameAudio(msgId, audioUri)) { mediaController.seekTo(position); } } - // Shouldn't need it for voice messages, but may be useful later - public void stop() { - if (mediaController != null) { + public void stop(int msgId, Uri audioUri) { + if (mediaController != null && isSameAudio(msgId, audioUri)) { mediaController.stop(); + stopUpdateProgress(); + playbackState.setValue(AudioPlaybackState.idle()); + } + } + + // A special method for deleting message, where we only use message Ids + public void stopByIds(int[] msgIds) { + AudioPlaybackState currentState = playbackState.getValue(); + + if (mediaController != null && currentState != null) { + for (int msgId : msgIds) { + if (msgId == currentState.getMsgId()) { + mediaController.stop(); + stopUpdateProgress(); + playbackState.setValue(AudioPlaybackState.idle()); + } + } } - stopUpdateProgress(); - playbackState.setValue(AudioPlaybackState.idle()); } public void setUserSeeking(boolean isUserSeeking) { diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java index e7748b4b3..1ae116391 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java @@ -39,7 +39,7 @@ public class AudioView extends FrameLayout { private final @NonNull View mask; private OnActionListener listener; - private int msgId; + private int msgId; private Uri audioUri; private AudioPlaybackViewModel viewModel; private final Observer stateObserver = this::onPlaybackStateChanged; @@ -99,9 +99,9 @@ private void setupControls() { if (state != null && msgId == state.getMsgId() && audioUri.equals(state.getAudioUri())) { // Same audio if (state.getStatus() == AudioPlaybackState.PlaybackStatus.PLAYING) { - viewModel.pause(); + viewModel.pause(msgId, audioUri); } else { - viewModel.play(); + viewModel.play(msgId, audioUri); } } else { // Different audio @@ -130,7 +130,7 @@ public void onStartTrackingTouch(SeekBar seekBar) { @Override public void onStopTrackingTouch(SeekBar seekBar) { viewModel.setUserSeeking(false); - viewModel.seekTo(seekBar.getProgress()); + viewModel.seekTo(seekBar.getProgress(), msgId, audioUri); } }); @@ -201,6 +201,14 @@ public void setOnLongClickListener(OnLongClickListener listener) { this.playPauseButton.setOnLongClickListener(listener); } + public int getMsgId() { + return msgId; + } + + public Uri getAudioUri() { + return audioUri; + } + public interface OnActionListener { void onPlayPauseButtonClicked(View view); } diff --git a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index d1e1b54d5..79447ce33 100644 --- a/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -121,7 +121,7 @@ private void inflateStub() { //this.mapView = ViewUtil.findById(root, R.id.attachment_location); this.removableMediaView = ViewUtil.findById(root, R.id.removable_media_view); - removableMediaView.setRemoveClickListener(new RemoveButtonListener()); + removableMediaView.addRemoveClickListener(new RemoveButtonListener()); removableMediaView.setEditClickListener(new EditButtonListener()); thumbnail.setOnClickListener(new ThumbnailClickListener()); } @@ -287,6 +287,9 @@ protected void onPostExecute(@Nullable final Slide slide) { audioView.setPlaybackViewModel(playbackViewModel); audioView.setAudio((AudioSlide) slide, 0); removableMediaView.display(audioView, false); + removableMediaView.addRemoveClickListener(v -> { + playbackViewModel.stop(audioView.getMsgId(), audioView.getAudioUri()); + }); result.set(true); } else if (slide.isVcard()) { vcardView.setVcard(glideRequests, (VcardSlide)slide, DcHelper.getRpc(context)); From 2889266522a9f522d08c49181b1e8ea1a6b42301 Mon Sep 17 00:00:00 2001 From: wch423 Date: Thu, 5 Feb 2026 16:28:38 +0100 Subject: [PATCH 15/19] Force dispatch all inset changes immediately in ConversationFragment --- .../org/thoughtcrime/securesms/ConversationFragment.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java index 1276f7a30..f09d0c9f6 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java @@ -164,14 +164,14 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, if (pendingAddBottomInsets) { bottomDivider.setVisibility(View.GONE); - ViewUtil.applyWindowInsets(list, false, true, false, true); + ViewUtil.forceApplyWindowInsets(list, false, true, false, true); ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, true); pendingAddBottomInsets = false; } if (pendingRemoveBottomInsets) { bottomDivider.setVisibility(View.VISIBLE); - ViewUtil.applyWindowInsets(list, false, true, false, false); + ViewUtil.forceApplyWindowInsets(list, false, true, false, false); ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, false); pendingRemoveBottomInsets = false; } @@ -217,7 +217,7 @@ else if(!dcChat.isEncrypted()) { public void handleAddBottomInsets() { if (bottomDivider != null) { bottomDivider.setVisibility(View.GONE); - ViewUtil.applyWindowInsets(list, false, true, false, true); + ViewUtil.forceApplyWindowInsets(list, false, true, false, true); ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, true); pendingAddBottomInsets = false; } else { @@ -228,7 +228,7 @@ public void handleAddBottomInsets() { public void handleRemoveBottomInsets() { if (bottomDivider != null) { bottomDivider.setVisibility(View.VISIBLE); - ViewUtil.applyWindowInsets(list, false, true, false, false); + ViewUtil.forceApplyWindowInsets(list, false, true, false, false); ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, false); pendingRemoveBottomInsets = false; } else { From 82118db71b2581baa35dd5395e818c1b93a26fea Mon Sep 17 00:00:00 2001 From: wch423 Date: Thu, 5 Feb 2026 17:27:21 +0100 Subject: [PATCH 16/19] Make inset changes only happen on initialization of activity --- .../securesms/ConversationActivity.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 0a77b617c..40c4d354c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -1001,22 +1001,26 @@ private void initializeResources() { recipient = new Recipient(this, dcChat); glideRequests = GlideApp.with(this); - setComposePanelVisibility(); + setComposePanelVisibility(true); initializeContactRequest(); } - private void setComposePanelVisibility() { + private void setComposePanelVisibility(boolean isInitialization) { if (dcChat.canSend()) { composePanel.setVisibility(View.VISIBLE); attachmentManager.setHidden(false); - ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, true); - fragment.handleRemoveBottomInsets(); + if (isInitialization) { + ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, true); + fragment.handleRemoveBottomInsets(); + } } else { composePanel.setVisibility(View.GONE); attachmentManager.setHidden(true); hideSoftKeyboard(); - ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); - fragment.handleAddBottomInsets(); + if (isInitialization) { + ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, false); + fragment.handleAddBottomInsets(); + } } } @@ -1582,7 +1586,7 @@ public void handleEvent(@NonNull DcEvent event) { dcChat = dcContext.getChat(chatId); titleView.setTitle(glideRequests, dcChat); initializeSecurity(isSecureText, isDefaultSms); - setComposePanelVisibility(); + setComposePanelVisibility(false); initializeContactRequest(); } else if ((eventId == DcContext.DC_EVENT_INCOMING_MSG || eventId == DcContext.DC_EVENT_MSG_READ) From 3386f5c5f72e3ad4e73224f7db55970d200d26ad Mon Sep 17 00:00:00 2001 From: wch423 Date: Thu, 5 Feb 2026 19:07:43 +0100 Subject: [PATCH 17/19] Make `!CanSend` -> `CanSend` still trigger Inset changes --- .../org/thoughtcrime/securesms/ConversationActivity.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 40c4d354c..b8db8fc4f 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -1009,10 +1009,8 @@ private void setComposePanelVisibility(boolean isInitialization) { if (dcChat.canSend()) { composePanel.setVisibility(View.VISIBLE); attachmentManager.setHidden(false); - if (isInitialization) { - ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, true); - fragment.handleRemoveBottomInsets(); - } + ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, true); + fragment.handleRemoveBottomInsets(); } else { composePanel.setVisibility(View.GONE); attachmentManager.setHidden(true); From 1c174b5b70ed3c65c4eee2069d072a80ffbfba7c Mon Sep 17 00:00:00 2001 From: wch423 Date: Thu, 5 Feb 2026 21:12:09 +0100 Subject: [PATCH 18/19] Stop draft audio playback when (1) the conversation exits; or (2) the message is sent --- .../org/thoughtcrime/securesms/ConversationActivity.java | 6 ++++++ .../components/audioplay/AudioPlaybackViewModel.java | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 5b2b50756..bbaaf02fb 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -679,6 +679,8 @@ private void handleReturnToConversationList(@Nullable Bundle extras) { extras.putInt(ConversationListFragment.RELOAD_LIST, 1); } + playbackViewModel.stopNonMessageAudioPlayback(); + boolean archived = getIntent().getBooleanExtra(FROM_ARCHIVED_CHATS_EXTRA, false); Intent intent = new Intent(this, (archived ? ConversationListArchiveActivity.class : ConversationListActivity.class)); intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); @@ -1151,6 +1153,10 @@ protected ListenableFuture processComposeControls(final int action, Str inputPanel.clearQuote(); } + // Stop draft audio playback regardless, since it is unlikely + // we will need background playback for drafts + playbackViewModel.stopNonMessageAudioPlayback(); + DcContext dcContext = DcHelper.getContext(context); Util.runOnAnyBackgroundThread(() -> { DcMsg msg = null; diff --git a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java index 9d6588467..85ea31652 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java @@ -17,6 +17,8 @@ public class AudioPlaybackViewModel extends ViewModel { private static final String TAG = AudioPlaybackViewModel.class.getSimpleName(); + private static final int NON_MESSAGE_AUDIO_MSG_ID = 0; // Audios not attached to a message doesn't have message id. + private final MutableLiveData playbackState; private @Nullable MediaController mediaController; private final Handler handler; @@ -97,8 +99,12 @@ public void stop(int msgId, Uri audioUri) { } } + public void stopNonMessageAudioPlayback() { + stopByIds(NON_MESSAGE_AUDIO_MSG_ID); + } + // A special method for deleting message, where we only use message Ids - public void stopByIds(int[] msgIds) { + public void stopByIds(int... msgIds) { AudioPlaybackState currentState = playbackState.getValue(); if (mediaController != null && currentState != null) { From 9ffc904ae513b2e60e23066bba360af9f14bc6b7 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Thu, 5 Feb 2026 21:40:38 +0100 Subject: [PATCH 19/19] more detailed call strings --- .../org/thoughtcrime/securesms/components/CallItemView.java | 4 +++- .../java/org/thoughtcrime/securesms/connect/DcHelper.java | 6 ++++-- src/main/res/values/strings.xml | 6 ++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/components/CallItemView.java b/src/main/java/org/thoughtcrime/securesms/components/CallItemView.java index 1cc06bec5..3ab0672ce 100644 --- a/src/main/java/org/thoughtcrime/securesms/components/CallItemView.java +++ b/src/main/java/org/thoughtcrime/securesms/components/CallItemView.java @@ -67,8 +67,10 @@ public void setCallItem(boolean isOutgoing, CallInfo callInfo) { title.setText(R.string.canceled_call); } else if (callInfo.state instanceof CallState.Declined) { title.setText(R.string.declined_call); + } else if (callInfo.hasVideo) { + title.setText(isOutgoing? R.string.outgoing_video_call : R.string.incoming_video_call); } else { - title.setText(isOutgoing? R.string.outgoing_call : R.string.incoming_call); + title.setText(isOutgoing? R.string.outgoing_audio_call : R.string.incoming_audio_call); } icon.setImageResource(callInfo.hasVideo? R.drawable.ic_videocam_white_24dp : R.drawable.baseline_call_24); diff --git a/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java b/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java index 6eadb77f8..aa8558646 100644 --- a/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java +++ b/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java @@ -210,8 +210,6 @@ public static void setStockTranslations(Context context) { dcContext.setStockTranslation(178, context.getString(R.string.member_x_removed)); dcContext.setStockTranslation(190, context.getString(R.string.secure_join_wait)); dcContext.setStockTranslation(193, context.getString(R.string.donate_device_msg)); - dcContext.setStockTranslation(194, context.getString(R.string.outgoing_call)); - dcContext.setStockTranslation(195, context.getString(R.string.incoming_call)); dcContext.setStockTranslation(196, context.getString(R.string.declined_call)); dcContext.setStockTranslation(197, context.getString(R.string.canceled_call)); dcContext.setStockTranslation(198, context.getString(R.string.missed_call)); @@ -223,6 +221,10 @@ public static void setStockTranslations(Context context) { dcContext.setStockTranslation(220, context.getString(R.string.proxy_enabled)); dcContext.setStockTranslation(221, context.getString(R.string.proxy_enabled_hint)); dcContext.setStockTranslation(230, context.getString(R.string.chat_unencrypted_explanation)); + dcContext.setStockTranslation(232, context.getString(R.string.outgoing_audio_call)); + dcContext.setStockTranslation(233, context.getString(R.string.outgoing_video_call)); + dcContext.setStockTranslation(234, context.getString(R.string.incoming_audio_call)); + dcContext.setStockTranslation(235, context.getString(R.string.incoming_video_call)); } public static File getImexDir() { diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index dc5f359b8..24cc287ff 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -386,8 +386,14 @@ Answer Decline + Outgoing call + Outgoing audio call + Outgoing video call + Incoming call + Incoming audio call + Incoming video call Declined call Canceled call Missed call