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