diff --git a/CHANGELOG-upstream.md b/CHANGELOG-upstream.md
index 426e5f680..6e58e44d7 100644
--- a/CHANGELOG-upstream.md
+++ b/CHANGELOG-upstream.md
@@ -15,6 +15,7 @@
* Add indication for blocked contacts in user profile
* Allow to start calls with video disabled
* 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/build.gradle b/build.gradle
index 67b0fa447..181818120 100644
--- a/build.gradle
+++ b/build.gradle
@@ -68,6 +68,7 @@ android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
+ coreLibraryDesugaringEnabled true
}
packagingOptions {
jniLibs {
@@ -211,6 +212,10 @@ dependencies {
implementation "io.noties.markwon:inline-parser:$markwon_version"
implementation 'com.airbnb.android:lottie:4.2.2' // Lottie animations support.
+ 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'
@@ -231,8 +236,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' // 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"
+ 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 c02237106..7d633f8ad 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -38,6 +38,7 @@
+
@@ -391,6 +392,15 @@
android:name=".service.FetchForegroundService"
android:foregroundServiceType="dataSync" />
+
+
+
+
+
+
diff --git a/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java
index 7ab97e7b4..56d2fdeb0 100644
--- a/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java
+++ b/src/main/java/org/thoughtcrime/securesms/AllMediaActivity.java
@@ -1,16 +1,24 @@
package org.thoughtcrime.securesms;
+import android.content.ComponentName;
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.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,11 +26,13 @@
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;
import java.util.ArrayList;
@@ -30,6 +40,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 +68,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 +106,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 +147,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 c582c7288..ede64b580 100644
--- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java
+++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsAdapter.java
@@ -11,9 +11,10 @@
import com.b44t.messenger.DcMsg;
import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter;
-import org.thoughtcrime.securesms.components.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;
@@ -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..439400c40 100644
--- a/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java
+++ b/src/main/java/org/thoughtcrime/securesms/AllMediaDocumentsFragment.java
@@ -16,6 +16,7 @@
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
+import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.RecyclerView;
@@ -25,6 +26,7 @@
import com.b44t.messenger.DcMsg;
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
+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;
@@ -72,9 +74,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);
@@ -239,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/BaseConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java
index 4b2072c1d..84526fc7b 100644
--- a/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java
+++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationItem.java
@@ -50,11 +50,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;
@@ -126,6 +126,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/BindableConversationItem.java b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
index af0740195..33f49a78a 100644
--- a/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
+++ b/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
@@ -6,6 +6,8 @@
import com.b44t.messenger.DcChat;
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;
@@ -17,7 +19,9 @@ void bind(@NonNull DcMsg messageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Set batchSelected,
@NonNull Recipient recipients,
- boolean pulseHighlight);
+ boolean pulseHighlight,
+ @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 bcf7f35e0..bd15fc926 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,12 @@
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.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;
@@ -76,7 +82,7 @@
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;
import org.thoughtcrime.securesms.components.ComposeText;
@@ -86,6 +92,8 @@
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.audioplay.AudioView;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.connect.AccountManager;
import org.thoughtcrime.securesms.connect.DcEventCenter;
@@ -104,19 +112,19 @@
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;
-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;
@@ -138,14 +146,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";
@@ -185,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;
@@ -217,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) {
@@ -267,6 +281,36 @@ 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();
+ 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);
@@ -337,7 +381,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
@@ -357,6 +400,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();
}
@@ -634,6 +682,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);
@@ -1007,20 +1057,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);
inputPanel.setSubjectVisible(!dcChat.isEncrypted());
+ ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, true);
+ fragment.handleRemoveBottomInsets();
} else {
composePanel.setVisibility(View.GONE);
attachmentManager.setHidden(true);
hideSoftKeyboard();
inputPanel.setSubjectVisible(false);
+ if (isInitialization) {
+ ViewUtil.forceApplyWindowInsets(findViewById(R.id.root_layout), true, false, true, false);
+ fragment.handleAddBottomInsets();
+ }
}
}
@@ -1057,11 +1113,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) {
@@ -1113,6 +1169,10 @@ protected ListenableFuture processComposeControls(final int action, Str
inputPanel.clearSubject();
}
+ // 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;
@@ -1425,6 +1485,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) {
@@ -1593,7 +1661,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)
diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java
index 10d30f9b0..5a8c66a6c 100644
--- a/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java
+++ b/src/main/java/org/thoughtcrime/securesms/ConversationAdapter.java
@@ -34,6 +34,8 @@
import com.b44t.messenger.DcMsg;
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;
@@ -59,6 +61,7 @@
* @author Moxie Marlinspike
*
*/
+// FIXME: this breaks type checks, that is why there are so many casts.
public class ConversationAdapter
extends RecyclerView.Adapter
implements StickyHeaderDecoration.StickyHeaderAdapter
@@ -97,6 +100,8 @@ public class ConversationAdapter
private long pulseHighlightingSince = -1;
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) {
@@ -170,6 +175,14 @@ public long getItemId(int position) {
return fromDb;
}
+ 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
*/
@@ -237,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);
+ 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 774e04004..3dce1f2ac 100644
--- a/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java
+++ b/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java
@@ -41,6 +41,7 @@
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
+import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
@@ -52,6 +53,7 @@
import com.b44t.messenger.DcMsg;
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;
@@ -101,6 +103,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;
@@ -108,6 +111,8 @@ public class ConversationFragment extends MessageSelectorFragment
public boolean isPaused;
private Debouncer markseenDebouncer;
private Rpc rpc;
+ private boolean pendingAddBottomInsets;
+ private boolean pendingRemoveBottomInsets;
@Override
public void onCreate(Bundle icicle) {
@@ -141,6 +146,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());
@@ -159,18 +165,32 @@ 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 (pendingAddBottomInsets) {
+ bottomDivider.setVisibility(View.GONE);
+ ViewUtil.forceApplyWindowInsets(list, false, true, false, true);
+ ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, true);
+ pendingAddBottomInsets = false;
+ }
+
+ if (pendingRemoveBottomInsets) {
+ bottomDivider.setVisibility(View.VISIBLE);
+ ViewUtil.forceApplyWindowInsets(list, false, true, false, false);
+ ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, false);
+ pendingRemoveBottomInsets = false;
+ }
+
return view;
}
@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 setNoMessageText() {
+ private void setNoMessageText() {
DcChat dcChat = getListAdapter().getChat();
if(dcChat.isMultiUser()){
if (dcChat.isInBroadcast() || dcChat.isOutBroadcast()) {
@@ -197,6 +217,28 @@ else if(!dcChat.isEncrypted()) {
}
}
+ public void handleAddBottomInsets() {
+ if (bottomDivider != null) {
+ bottomDivider.setVisibility(View.GONE);
+ ViewUtil.forceApplyWindowInsets(list, false, true, false, true);
+ ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, true);
+ pendingAddBottomInsets = false;
+ } else {
+ pendingAddBottomInsets = true;
+ }
+ }
+
+ public void handleRemoveBottomInsets() {
+ if (bottomDivider != null) {
+ bottomDivider.setVisibility(View.VISIBLE);
+ ViewUtil.forceApplyWindowInsets(list, false, true, false, false);
+ ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, false);
+ pendingRemoveBottomInsets = false;
+ } else {
+ pendingRemoveBottomInsets = true;
+ }
+ }
+
@Override
public void onDestroy() {
DcHelper.getEventCenter(getContext()).removeObservers(this);
@@ -291,6 +333,10 @@ 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);
+ adapter.setAudioPlayPauseListener(((ConversationActivity) requireActivity()));
if (dateDecoration != null) {
list.removeItemDecoration(dateDecoration);
@@ -407,7 +453,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() {
@@ -940,12 +993,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/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java
index dffda6132..bdeba83dc 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.audio.AudioSlidePlayer;
-import org.thoughtcrime.securesms.components.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;
@@ -71,7 +72,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;
@@ -181,9 +181,11 @@ public void bind(@NonNull DcMsg messageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Set batchSelected,
@NonNull Recipient recipients,
- boolean pulseHighlight)
+ boolean pulseHighlight,
+ @Nullable AudioPlaybackViewModel playbackViewModel,
+ AudioView.OnActionListener audioPlayPauseListener)
{
- 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;
@@ -204,7 +206,7 @@ public void bind(@NonNull DcMsg messageRecord,
setGutterSizes(messageRecord, showSender);
setMessageShape(messageRecord);
- setMediaAttributes(messageRecord, showSender);
+ setMediaAttributes(messageRecord, showSender, playbackViewModel, audioPlayPauseListener);
setBodyText(messageRecord);
setBubbleState(messageRecord);
setContactPhoto();
@@ -482,24 +484,10 @@ else if (messageRecord.hasHtml()) {
}
private void setMediaAttributes(@NonNull DcMsg messageRecord,
- boolean showSender)
+ boolean showSender,
+ AudioPlaybackViewModel playbackViewModel,
+ AudioView.OnActionListener audioPlayPauseListener)
{
- 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);
@@ -511,12 +499,9 @@ 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().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 4e484a368..bf1fe4afa 100644
--- a/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java
+++ b/src/main/java/org/thoughtcrime/securesms/ConversationUpdateItem.java
@@ -15,6 +15,8 @@
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;
@@ -61,9 +63,11 @@ public void bind(@NonNull DcMsg messageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Set batchSelected,
@NonNull Recipient conversationRecipient,
- boolean pulseUpdate)
+ boolean pulseUpdate,
+ @Nullable AudioPlaybackViewModel playbackViewModel,
+ AudioView.OnActionListener audioPlayPauseListener)
{
- bind(messageRecord, dcChat, batchSelected, pulseUpdate, conversationRecipient);
+ bindPartial(messageRecord, dcChat, batchSelected, pulseUpdate, conversationRecipient);
setGenericInfoRecord(messageRecord);
}
diff --git a/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java b/src/main/java/org/thoughtcrime/securesms/MessageSelectorFragment.java
index 7eb050730..ba4e936f5 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/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/audio/AudioSlidePlayer.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
deleted file mode 100644
index 20cae692d..000000000
--- a/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
+++ /dev/null
@@ -1,356 +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;
-
-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/AudioView.java b/src/main/java/org/thoughtcrime/securesms/components/AudioView.java
deleted file mode 100644
index ee73d0658..000000000
--- a/src/main/java/org/thoughtcrime/securesms/components/AudioView.java
+++ /dev/null
@@ -1,301 +0,0 @@
-package org.thoughtcrime.securesms.components;
-
-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.util.AttributeSet;
-import android.util.Log;
-import android.view.View;
-import android.widget.FrameLayout;
-import android.widget.ImageView;
-import android.widget.SeekBar;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
-import org.thoughtcrime.securesms.mms.AudioSlide;
-import org.thoughtcrime.securesms.util.DateUtils;
-
-import java.io.IOException;
-
-
-public class AudioView extends FrameLayout implements AudioSlidePlayer.Listener {
-
- 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 SeekBar seekBar;
- private final @NonNull TextView timestamp;
- private final @NonNull TextView title;
- private final @NonNull View mask;
-
- private @Nullable AudioSlidePlayer audioSlidePlayer;
- private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
- private int backwardsCounter;
-
- public AudioView(Context context) {
- this(context, null);
- }
-
- public AudioView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- }
-
- 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.mask = findViewById(R.id.interception_mask);
-
- 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));
-
- setTint(getContext().getResources().getColor(R.color.audio_icon));
- }
-
- public void setAudio(final @NonNull AudioSlide audio, int duration)
- {
- controlToggle.displayQuick(playButton);
- seekBar.setEnabled(true);
- seekBar.setProgress(0);
- audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
- timestamp.setText(DateUtils.getFormatedDuration(duration));
-
- if(audio.asAttachment().isVoiceNote() || !audio.getFileName().isPresent()) {
- title.setVisibility(View.GONE);
- }
- else {
- title.setText(audio.getFileName().get());
- title.setVisibility(View.VISIBLE);
- }
- }
-
- @Override
- public void setOnClickListener(OnClickListener listener) {
- super.setOnClickListener(listener);
- this.mask.setOnClickListener(listener);
- }
-
- @Override
- public void setOnLongClickListener(OnLongClickListener listener) {
- super.setOnLongClickListener(listener);
- this.mask.setOnLongClickListener(listener);
- this.playButton.setOnLongClickListener(listener);
- this.pauseButton.setOnLongClickListener(listener);
- }
-
- public void togglePlay() {
- if (this.playButton.getVisibility() == View.VISIBLE) {
- playButton.performClick();
- } else {
- pauseButton.performClick();
- }
- }
-
- public String getDescription() {
- String desc;
- if (this.title.getVisibility() == View.VISIBLE) {
- desc = getContext().getString(R.string.audio);
- } else {
- desc = getContext().getString(R.string.voice_message);
- }
- desc += "\n" + this.timestamp.getText();
- if (title.getVisibility() == View.VISIBLE) {
- desc += "\n" + this.title.getText();
- }
- return desc;
- }
-
- 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();
- }
- }
-
- @Override
- public void onReceivedDuration(int millis) {
- this.timestamp.setText(DateUtils.getFormatedDuration(millis));
- }
-
- @Override
- public void onStart() {
- if (this.pauseButton.getVisibility() != View.VISIBLE) {
- togglePlayToPause();
- }
- }
-
- @Override
- public void onStop() {
- if (this.playButton.getVisibility() != View.VISIBLE) {
- togglePauseToPlay();
- }
-
- if (seekBar.getProgress() + 5 >= seekBar.getMax()) {
- backwardsCounter = 4;
- onProgress(audioSlidePlayer.getAudioSlide(), 0.0, -1);
- }
- }
-
- 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));
-
- 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);
- }
-
- 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 togglePlayToPause() {
- controlToggle.displayQuick(pauseButton);
-
- AnimatedVectorDrawable playToPauseDrawable = (AnimatedVectorDrawable) getContext().getDrawable(R.drawable.play_to_pause_animation);
- pauseButton.setImageDrawable(playToPauseDrawable);
- playToPauseDrawable.start();
- }
-
- private void togglePauseToPlay() {
- controlToggle.displayQuick(playButton);
-
- AnimatedVectorDrawable pauseToPlayDrawable = (AnimatedVectorDrawable) getContext().getDrawable(R.drawable.pause_to_play_animation);
- 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/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/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/AudioPlaybackState.java b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java
new file mode 100644
index 000000000..09868ed25
--- /dev/null
+++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackState.java
@@ -0,0 +1,58 @@
+package org.thoughtcrime.securesms.components.audioplay;
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+public class AudioPlaybackState {
+ private final int msgId;
+ 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(int msgId,
+ @Nullable Uri audioUri,
+ PlaybackStatus status,
+ long currentPosition,
+ long duration) {
+ this.msgId = msgId;
+ this.audioUri = audioUri;
+ this.status = status;
+ this.currentPosition = currentPosition;
+ this.duration = duration;
+ }
+
+ public static AudioPlaybackState idle() {
+ return new AudioPlaybackState(0, null, PlaybackStatus.IDLE, 0, 0);
+ }
+
+ public int getMsgId() {
+ return msgId;
+ }
+
+ @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..85ea31652
--- /dev/null
+++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioPlaybackViewModel.java
@@ -0,0 +1,243 @@
+package org.thoughtcrime.securesms.components.audioplay;
+
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+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;
+
+
+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;
+ 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;
+ if (mediaController != null && mediaController.isPlaying()) {
+ startUpdateProgress();
+ }
+ updateCurrentState(true);
+ setupPlayerListener();
+ }
+
+ // Public methods
+ public void loadAudioAndPlay(int msgId, Uri audioUri) {
+ if (mediaController == null) return;
+
+ // Set media item if we have a different audio.
+ if (isDifferentAudio(msgId, audioUri)) {
+ updateState(msgId, audioUri, AudioPlaybackState.PlaybackStatus.LOADING, 0, 0);
+
+ MediaItem mediaItem = new MediaItem.Builder()
+ .setMediaId(String.valueOf(msgId))
+ .setUri(audioUri)
+ .build();
+ mediaController.setMediaItem(mediaItem);
+ mediaController.prepare();
+ }
+
+ 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();
+
+ 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(int msgId, Uri audioUri) {
+ if (mediaController != null && isSameAudio(msgId, audioUri)) {
+ mediaController.play();
+ }
+ }
+
+ public void seekTo(long position, int msgId, Uri audioUri) {
+ if (mediaController != null && isSameAudio(msgId, audioUri)) {
+ mediaController.seekTo(position);
+ }
+ }
+
+ public void stop(int msgId, Uri audioUri) {
+ if (mediaController != null && isSameAudio(msgId, audioUri)) {
+ mediaController.stop();
+ stopUpdateProgress();
+ playbackState.setValue(AudioPlaybackState.idle());
+ }
+ }
+
+ 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) {
+ AudioPlaybackState currentState = playbackState.getValue();
+
+ if (mediaController != null && currentState != null) {
+ for (int msgId : msgIds) {
+ if (msgId == currentState.getMsgId()) {
+ 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(false);
+ }
+ if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
+ if (player.getPlaybackState() == Player.STATE_READY) {
+ 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
+ mediaController.setPlayWhenReady(false);
+ }
+ }
+ if (events.containsAny(Player.EVENT_PLAYER_ERROR)) {
+ updateCurrentAudioState(AudioPlaybackState.PlaybackStatus.ERROR, 0, 0);
+ }
+ }
+ });
+ }
+
+ private void updateCurrentState(boolean queryPlaying) {
+ 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;
+ }
+
+ Uri currentUri = null;
+ int currentMsgId = 0;
+ if (playbackState.getValue() != null) {
+ currentMsgId = playbackState.getValue().getMsgId();
+ currentUri = playbackState.getValue().getAudioUri();
+ }
+ if (queryPlaying || playbackState.getValue() == null) {
+ MediaItem item = mediaController.getCurrentMediaItem();
+ 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(
+ currentMsgId,
+ currentUri,
+ status,
+ mediaController.getCurrentPosition(),
+ mediaController.getDuration());
+ }
+
+ private void updateState(int msgId,
+ Uri audioUri,
+ AudioPlaybackState.PlaybackStatus status,
+ long position,
+ long duration) {
+ playbackState.setValue(new AudioPlaybackState(
+ msgId, audioUri, status, position, duration
+ ));
+ }
+
+ private void updateCurrentAudioState(AudioPlaybackState.PlaybackStatus status,
+ long position,
+ long duration) {
+ AudioPlaybackState current = playbackState.getValue();
+
+ if (current != null) {
+ updateState(current.getMsgId(), 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);
+ }
+ }
+ };
+
+ private void startUpdateProgress() {
+ stopUpdateProgress();
+ handler.post(progressRunnable);
+ }
+
+ private void stopUpdateProgress() {
+ handler.removeCallbacks(progressRunnable);
+ }
+
+ @Override
+ protected void 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
new file mode 100644
index 000000000..1ae116391
--- /dev/null
+++ b/src/main/java/org/thoughtcrime/securesms/components/audioplay/AudioView.java
@@ -0,0 +1,311 @@
+package org.thoughtcrime.securesms.components.audioplay;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.lifecycle.Observer;
+import androidx.vectordrawable.graphics.drawable.Animatable2Compat;
+import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.mms.AudioSlide;
+import org.thoughtcrime.securesms.util.DateUtils;
+
+
+public class AudioView extends FrameLayout {
+
+ private static final String TAG = AudioView.class.getSimpleName();
+
+ private final @NonNull ImageView playPauseButton;
+ private final AnimatedVectorDrawableCompat playToPauseDrawable;
+ 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;
+ private final @NonNull View mask;
+ private OnActionListener listener;
+
+ private int msgId;
+ private Uri audioUri;
+ private AudioPlaybackViewModel viewModel;
+ private final Observer stateObserver = this::onPlaybackStateChanged;
+ private boolean isPlaying;
+
+ public AudioView(Context context) {
+ this(context, null);
+ }
+
+ public AudioView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ inflate(context, R.layout.audio_view, this);
+
+ 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");
+
+ // 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);
+
+ this.animationCallback = new Animatable2Compat.AnimationCallback() {
+ @Override
+ public void onAnimationEnd(Drawable drawable) {
+ Drawable endState = isPlaying ? pauseDrawable : playDrawable;
+ playPauseButton.setImageDrawable(endState);
+ }
+ };
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ setupControls();
+ }
+
+ private void setupControls() {
+ playPauseButton.setOnClickListener(v -> {
+ Log.w(TAG, "playPauseButton onClick");
+
+ if (viewModel == null || audioUri == null) return;
+
+ AudioPlaybackState state = viewModel.getPlaybackState().getValue();
+
+ if (state != null && msgId == state.getMsgId() && audioUri.equals(state.getAudioUri())) {
+ // Same audio
+ if (state.getStatus() == AudioPlaybackState.PlaybackStatus.PLAYING) {
+ viewModel.pause(msgId, audioUri);
+ } else {
+ viewModel.play(msgId, audioUri);
+ }
+ } else {
+ // Different audio
+ // 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() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ if (fromUser) {
+ timestamp.setText(DateUtils.getFormatedDuration(progress));
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ viewModel.setUserSeeking(true);
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ viewModel.setUserSeeking(false);
+ viewModel.seekTo(seekBar.getProgress(), msgId, audioUri);
+ }
+ });
+
+ if (playToPauseDrawable != null) {
+ playToPauseDrawable.registerAnimationCallback(animationCallback);
+ }
+ if (pauseToPlayDrawable != null) {
+ pauseToPlayDrawable.registerAnimationCallback(animationCallback);
+ }
+ }
+
+ @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) {
+ 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)
+ {
+ msgId = audio.getDcMsgId();
+ audioUri = audio.getUri();
+ playPauseButton.setImageDrawable(playDrawable);
+
+ seekBar.setEnabled(true);
+ seekBar.setProgress(0);
+ timestamp.setText(DateUtils.getFormatedDuration(duration));
+
+ if(audio.asAttachment().isVoiceNote() || !audio.getFileName().isPresent()) {
+ title.setVisibility(View.GONE);
+ }
+ else {
+ title.setText(audio.getFileName().get());
+ title.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ public void setOnClickListener(OnClickListener listener) {
+ super.setOnClickListener(listener);
+ this.mask.setOnClickListener(listener);
+ }
+
+ @Override
+ public void setOnLongClickListener(OnLongClickListener listener) {
+ super.setOnLongClickListener(listener);
+ this.mask.setOnLongClickListener(listener);
+ this.playPauseButton.setOnLongClickListener(listener);
+ }
+
+ public int getMsgId() {
+ return msgId;
+ }
+
+ public Uri getAudioUri() {
+ return audioUri;
+ }
+
+ public interface OnActionListener {
+ void onPlayPauseButtonClicked(View view);
+ }
+
+ public void setOnActionListener(OnActionListener listener) {
+ this.listener = listener;
+ }
+
+ public void togglePlay() {
+ playPauseButton.performClick();
+ }
+
+ public String getDescription() {
+ String desc;
+ if (this.title.getVisibility() == View.VISIBLE) {
+ desc = getContext().getString(R.string.audio);
+ } else {
+ desc = getContext().getString(R.string.voice_message);
+ }
+ desc += "\n" + this.timestamp.getText();
+ if (title.getVisibility() == View.VISIBLE) {
+ desc += "\n" + this.title.getText();
+ }
+ return desc;
+ }
+
+ private void updateProgress(AudioPlaybackState state) {
+ int duration = (int) state.getDuration();
+ int position = (int) state.getCurrentPosition();
+
+ if (duration > 0) {
+ seekBar.setMax(duration);
+ seekBar.setProgress(position);
+ timestamp.setText(DateUtils.getFormatedDuration(position));
+ }
+ }
+
+ public void disablePlayer(boolean disable) {
+ this.mask.setVisibility(disable? View.VISIBLE : View.GONE);
+ }
+
+ public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) {
+ seekBar.getGlobalVisibleRect(rect);
+ }
+
+ private void togglePlayPause(boolean expectedPlaying) {
+ isPlaying = expectedPlaying;
+ Drawable expectedDrawable = expectedPlaying ? pauseDrawable : playDrawable;
+
+ 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);
+
+ if (animDrawable != null) {
+ playPauseButton.setImageDrawable(animDrawable);
+ playPauseButton.setContentDescription(contentDescription);
+
+ animDrawable.start();
+ }
+ }
+ }
+
+ private void onPlaybackStateChanged(AudioPlaybackState state) {
+ if (audioUri == null || state == null) return;
+
+ // Check if this state is about this message
+ boolean isThisMessage = msgId == state.getMsgId() && audioUri.equals(state.getAudioUri());
+
+ if (isThisMessage) {
+ updateUIForPlaybackState(state);
+ } else {
+ togglePlayPause(false);
+ }
+ }
+
+ private void updateUIForPlaybackState(AudioPlaybackState state) {
+ switch (state.getStatus()) {
+ case PLAYING:
+ togglePlayPause(true);
+ updateProgress(state);
+ break;
+
+ case PAUSED:
+ togglePlayPause(false);
+ updateProgress(state);
+ break;
+
+ case LOADING:
+ case ERROR:
+ // No special handling yet
+ break;
+ }
+ }
+}
diff --git a/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java b/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java
index 6cf79f59d..1934ed092 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/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java
index fb817e212..71ece6c8a 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.AudioView;
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;
import org.thoughtcrime.securesms.geolocation.DcLocationManager;
@@ -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());
}
@@ -152,8 +152,6 @@ public void onFailure(ExecutionException e) {
markGarbage(getSlideUri());
slide = Optional.absent();
-
- audioView.cleanup();
}
}
@@ -235,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();
@@ -285,26 +284,12 @@ 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.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));
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..8cbe1de63
--- /dev/null
+++ b/src/main/java/org/thoughtcrime/securesms/service/AudioPlaybackService.java
@@ -0,0 +1,140 @@
+package org.thoughtcrime.securesms.service;
+
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.os.Bundle;
+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.ConversationListActivity;
+
+public class AudioPlaybackService extends MediaSessionService {
+
+ private static final String TAG = AudioPlaybackService.class.getSimpleName();
+
+ 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();
+
+ // This is for click on the notification to go back to app
+ Intent intent = new Intent(this, ConversationListActivity.class);
+ 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(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) {
+ return session;
+ }
+
+ @Override
+ public void onDestroy() {
+ if (session != null) {
+ session.release();
+ session = null;
+ }
+ if (player != null) {
+ player.release();
+ player = null;
+ }
+ super.onDestroy();
+ }
+}
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);
+ }
}
}
diff --git a/src/main/res/layout/audio_view.xml b/src/main/res/layout/audio_view.xml
index 0e2206351..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"/>
-
-
-
- Answer
Decline
+
Outgoing call
+ Outgoing audio call
+ Outgoing video call
+
Incoming call
+ Incoming audio call
+ Incoming video call
Declined call
Canceled call
Missed call