Compare commits

...

28 Commits

Author SHA1 Message Date
adbenitez 93427ba412 Merge remote-tracking branch 'upstream/wch423/audio-background-play' into adb/test-bg-audio 2026-02-06 19:04:39 +01:00
adb 30124de2a8 Merge branch 'main' into wch423/audio-background-play 2026-02-06 18:34:56 +01:00
B. Petersen 9ffc904ae5 more detailed call strings 2026-02-06 11:45:56 +01:00
wchen342 2704749b44 Merge pull request #4198 from deltachat/wch423/edge-to-edge-device-channel
Make device messages and subscribed channels edge to edge
2026-02-05 15:15:40 -05:00
wch423 1c174b5b70 Stop draft audio playback when (1) the conversation exits; or (2) the message is sent 2026-02-05 21:12:09 +01:00
adb f022316ad7 Merge branch 'main' into wch423/audio-background-play 2026-02-05 19:44:50 +01:00
wch423 3386f5c5f7 Make !CanSend -> CanSend still trigger Inset changes 2026-02-05 19:07:43 +01:00
adb 57eead3a34 Merge branch 'main' into wch423/edge-to-edge-device-channel 2026-02-05 18:33:52 +01:00
wch423 82118db71b Make inset changes only happen on initialization of activity 2026-02-05 17:27:21 +01:00
adb cebfa12142 Merge branch 'main' into wch423/edge-to-edge-device-channel 2026-02-05 16:55:30 +01:00
wch423 2889266522 Force dispatch all inset changes immediately in ConversationFragment 2026-02-05 16:28:38 +01:00
adb 5e6fccf143 Merge branch 'main' into wch423/edge-to-edge-device-channel 2026-02-05 15:41:17 +01:00
wch423 71ed333468 Stop playback when attachment preview is removed;
Stop playback when message containing audio is removed
2026-02-04 22:49:18 +01:00
wch423 b66bc1f863 Handle specific case when leaving group; also move floating button to avoid overaapping with system bar 2026-02-04 19:36:51 +01:00
adb aebd5c66f7 Merge branch 'main' into wch423/audio-background-play 2026-02-03 21:51:02 +01:00
wch423 f319ba2b83 Allow list scrolling to extend to edge 2026-02-03 19:40:02 +01:00
wch423 93f12e7367 Make message list respect bottom bar height 2026-02-03 19:40:00 +01:00
wch423 10acb07f82 Make device messages and subscribed channels edge to edge 2026-02-03 19:39:56 +01:00
wch423 13374df709 Bug fixes and minor changes 2026-02-03 17:41:31 +01:00
adb 1ff4e069ea Merge branch 'main' into wch423/audio-background-play 2026-01-29 22:17:24 +01:00
wch423 34be7aab17 Cleanup imports 2026-01-28 21:30:51 +01:00
wch423 59139ed242 Add support for attachment draft; Distinguish between different messages; Prevent unrelated activities from changing the pending intent 2026-01-28 21:28:43 +01:00
wch423 11f3964bdc Add support for attachment draft; Distinguish between different messages 2026-01-28 21:28:42 +01:00
wch423 7683408d18 Allow going back to previous activity from the notification; Support AllMediaActivity 2026-01-28 21:28:40 +01:00
wch423 b74e793654 Finish ViewModel for audio playback; Refine UI set up for AudioView 2026-01-28 21:28:38 +01:00
wch423 4bd74324d2 Try to decouple view and business logics 2026-01-28 21:28:36 +01:00
wch423 9a121b3039 Fix UI update problems;
Make notification clickable
2026-01-28 21:28:33 +01:00
wch423 a9832c9c53 Make audio view use Media3 for playback 2026-01-28 21:28:31 +01:00
33 changed files with 1172 additions and 814 deletions
+1
View File
@@ -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
+9 -1
View File
@@ -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
+10
View File
@@ -38,6 +38,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- force compiling emojipicker on sdk<21; runtime checks are required then -->
<uses-sdk tools:overrideLibrary="androidx.emoji2.emojipicker"/>
@@ -391,6 +392,15 @@
android:name=".service.FetchForegroundService"
android:foregroundServiceType="dataSync" />
<service
android:name=".service.AudioPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<receiver android:name=".notifications.MarkReadReceiver"
android:enabled="true"
android:exported="false">
@@ -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.DcContext;
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 @@ import java.util.ArrayList;
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 @@ public class AllMediaActivity extends PassphraseRequiredActionBarActivity
private TabLayout tabLayout;
private ViewPager viewPager;
private @Nullable MediaController mediaController;
private ListenableFuture<MediaController> mediaControllerFuture;
private AudioPlaybackViewModel playbackViewModel;
@Override
protected void onPreCreate() {
dynamicTheme = new DynamicNoActionBarTheme();
@@ -91,11 +106,19 @@ public class AllMediaActivity extends PassphraseRequiredActionBarActivity
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 @@ public class AllMediaActivity extends PassphraseRequiredActionBarActivity
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;
}
@@ -11,9 +11,10 @@ import androidx.annotation.NonNull;
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<DcMsg> 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 @@ class AllMediaDocumentsAdapter extends StickyHeaderGridAdapter {
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 @@ class AllMediaDocumentsAdapter extends StickyHeaderGridAdapter {
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; });
@@ -16,6 +16,7 @@ import android.widget.TextView;
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.DcEvent;
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 class AllMediaDocumentsFragment
// 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 class AllMediaDocumentsFragment
@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) {
@@ -50,11 +50,11 @@ public abstract class BaseConversationItem extends LinearLayout
this.rpc = DcHelper.getRpc(context);
}
protected void bind(@NonNull DcMsg messageRecord,
@NonNull DcChat dcChat,
@NonNull Set<DcMsg> batchSelected,
boolean pulseHighlight,
@NonNull Recipient conversationRecipient)
protected void bindPartial(@NonNull DcMsg messageRecord,
@NonNull DcChat dcChat,
@NonNull Set<DcMsg> batchSelected,
boolean pulseHighlight,
@NonNull Recipient conversationRecipient)
{
this.messageRecord = messageRecord;
this.dcChat = dcChat;
@@ -126,6 +126,8 @@ public abstract class BaseConversationItem extends LinearLayout
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();
}
@@ -6,6 +6,8 @@ import androidx.annotation.Nullable;
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 @@ public interface BindableConversationItem extends Unbindable {
@NonNull GlideRequests glideRequests,
@NonNull Set<DcMsg> batchSelected,
@NonNull Recipient recipients,
boolean pulseHighlight);
boolean pulseHighlight,
@Nullable AudioPlaybackViewModel playbackViewModel,
AudioView.OnActionListener audioPlayPauseListener);
DcMsg getMessageRecord();
@@ -25,6 +25,7 @@ import android.Manifest;
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.ActionBar;
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 com.b44t.messenger.DcMsg;
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.InputPanel;
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.permissions.Permissions;
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 @@ import chat.delta.util.SettableFuture;
*/
@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<MediaController> mediaControllerFuture;
private AudioPlaybackViewModel playbackViewModel;
private ApplicationContext context;
private Recipient recipient;
@@ -217,6 +227,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeActionBar();
initializeViews();
initializeResources();
playbackViewModel = new ViewModelProvider(this).get(AudioPlaybackViewModel.class);
initializeMediaController();
initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
@@ -267,6 +281,36 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
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 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
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 class ConversationActivity extends PassphraseRequiredActionBarActivity
@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 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
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 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
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 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
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<Boolean> 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 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
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 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
// 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 class ConversationActivity extends PassphraseRequiredActionBarActivity
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)
@@ -34,6 +34,8 @@ import com.b44t.messenger.DcContext;
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 @@ import java.util.Set;
* @author Moxie Marlinspike
*
*/
// FIXME: this breaks type checks, that is why there are so many casts.
public class ConversationAdapter <V extends View & BindableConversationItem>
extends RecyclerView.Adapter
implements StickyHeaderDecoration.StickyHeaderAdapter<HeaderViewHolder>
@@ -97,6 +100,8 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
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 <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
@@ -170,6 +175,14 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
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 class ConversationAdapter <V extends View & BindableConversationItem>
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
@@ -41,6 +41,7 @@ import android.widget.Toast;
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.DcEvent;
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 class ConversationFragment extends MessageSelectorFragment
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 class ConversationFragment extends MessageSelectorFragment
// 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 @@ public class ConversationFragment extends MessageSelectorFragment
}
}
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 @@ public class ConversationFragment extends MessageSelectorFragment
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 @@ public class ConversationFragment extends MessageSelectorFragment
}
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 class ConversationFragment extends MessageSelectorFragment
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);
@@ -41,8 +41,7 @@ import com.b44t.messenger.DcChat;
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.DocumentView;
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.MediaUtil;
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 class ConversationItem extends BaseConversationItem
@NonNull GlideRequests glideRequests,
@NonNull Set<DcMsg> 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 class ConversationItem extends BaseConversationItem
setGutterSizes(messageRecord, showSender);
setMessageShape(messageRecord);
setMediaAttributes(messageRecord, showSender);
setMediaAttributes(messageRecord, showSender, playbackViewModel, audioPlayPauseListener);
setBodyText(messageRecord);
setBubbleState(messageRecord);
setContactPhoto();
@@ -482,24 +484,10 @@ public class ConversationItem extends BaseConversationItem
}
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 class ConversationItem extends BaseConversationItem
//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);
@@ -15,6 +15,8 @@ import com.b44t.messenger.DcMsg;
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 class ConversationUpdateItem extends BaseConversationItem
@NonNull GlideRequests glideRequests,
@NonNull Set<DcMsg> 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);
}
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms;
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.StorageUtil;
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 @@ public abstract class MessageSelectorFragment
}
protected void handleDeleteMessages(int chatId, final Set<DcMsg> 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<DcMsg> messageRecords, Consumer<int[]> deleteForMeListenerExtra, Consumer<int[]> deleteForAllListenerExtra) {
handleDeleteMessages(chatId, DcMsg.msgSetToIds(messageRecords), deleteForMeListenerExtra, deleteForAllListenerExtra);
}
protected void handleDeleteMessages(int chatId, final int[] messageIds, Consumer<int[]> deleteForMeListenerExtra, Consumer<int[]> deleteForAllListenerExtra) {
DcContext dcContext = DcHelper.getContext(getContext());
DcChat dcChat = dcContext.getChat(chatId);
boolean canDeleteForAll = true;
@@ -79,20 +85,24 @@ public abstract class MessageSelectorFragment
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);
@@ -77,7 +77,7 @@ public class WebViewActivity extends PassphraseRequiredActionBarActivity
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() {
@@ -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<AudioSlidePlayer> playing = Optional.absent();
private final @NonNull Context context;
private final @NonNull AudioSlide slide;
private final @NonNull Handler progressEventHandler;
private @NonNull WeakReference<Listener> 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<Double, Integer> getProgress() {
if (mediaPlayer == null || mediaPlayer.getCurrentPosition() <= 0 || mediaPlayer.getDuration() <= 0) {
return new Pair<>(0D, 0);
} else {
return new Pair<>((double) mediaPlayer.getCurrentPosition() / (double) mediaPlayer.getDuration(),
(int) mediaPlayer.getCurrentPosition());
}
}
private void notifyOnStart() {
Util.runOnMain(new Runnable() {
@Override
public void run() {
getListener().onStart();
}
});
}
private void notifyOnStop() {
Util.runOnMain(new Runnable() {
@Override
public void run() {
getListener().onStop();
}
});
}
private void notifyOnProgress(final double progress, final long millis) {
Util.runOnMain(new Runnable() {
@Override
public void run() {
getListener().onProgress(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<AudioSlidePlayer> 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<Double, Integer> progress = player.getProgress();
player.notifyOnProgress(progress.first, progress.second);
sendEmptyMessageDelayed(0, 50);
}
private boolean isPlayerActive(@NonNull SimpleExoPlayer player) {
return player.getPlaybackState() == Player.STATE_READY || player.getPlaybackState() == Player.STATE_BUFFERING;
}
}
}
@@ -1,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);
}
}
}
}
@@ -67,8 +67,10 @@ public class CallItemView extends FrameLayout {
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);
@@ -11,6 +11,9 @@ import android.widget.ImageView;
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<OnClickListener> removeClickListeners = new ArrayList<>();
public RemovableEditableMediaView(Context context) {
this(context, null);
@@ -72,8 +76,22 @@ public class RemovableEditableMediaView extends FrameLayout {
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) {
@@ -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;
}
}
@@ -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<AudioPlaybackState> 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<AudioPlaybackState> 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();
}
}
@@ -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<AudioPlaybackState> 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;
}
}
}
@@ -210,8 +210,6 @@ public class DcHelper {
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 class DcHelper {
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() {
@@ -49,13 +49,13 @@ import org.thoughtcrime.securesms.WebxdcActivity;
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 @@ public class AttachmentManager {
//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 class AttachmentManager {
markGarbage(getSlideUri());
slide = Optional.absent();
audioView.cleanup();
}
}
@@ -235,7 +233,8 @@ public class AttachmentManager {
@NonNull final MediaType mediaType,
final int width,
final int height,
final int chatId)
final int chatId,
AudioPlaybackViewModel playbackViewModel)
{
inflateStub();
@@ -285,26 +284,12 @@ public class AttachmentManager {
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));
@@ -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<SessionResult> 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();
}
}
@@ -323,7 +323,15 @@ public class ViewUtil {
* @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 class ViewUtil {
* @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 class ViewUtil {
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 class ViewUtil {
// 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 class ViewUtil {
* @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 class ViewUtil {
* @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 class ViewUtil {
* @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 class ViewUtil {
// 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);
}
}
}
+19 -37
View File
@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context="org.thoughtcrime.securesms.components.AudioView">
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout android:id="@+id/audio_widget_container"
android:orientation="vertical"
@@ -12,39 +10,19 @@
android:layout_height="fill_parent"
android:orientation="horizontal">
<org.thoughtcrime.securesms.components.AnimatingToggle
android:id="@+id/control_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center">
<ImageView android:id="@+id/play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:clickable="true"
android:visibility="gone"
android:background="@drawable/circle_touch_highlight_background"
android:src="@drawable/ic_play_circle_fill_white_48dp"
android:scaleType="centerInside"
tools:visibility="gone"
android:contentDescription="@string/menu_play"/>
<ImageView android:id="@+id/pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:clickable="true"
android:visibility="gone"
android:background="@drawable/circle_touch_highlight_background"
android:src="@drawable/ic_pause_circle_fill_white_48dp"
android:scaleType="centerInside"
android:contentDescription="@string/menu_pause"/>
</org.thoughtcrime.securesms.components.AnimatingToggle>
<ImageView android:id="@+id/play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:focusable="true"
android:gravity="center_vertical"
android:clickable="true"
android:visibility="visible"
android:background="@drawable/ic_circle_fill_white_48dp"
android:backgroundTint="@color/audio_icon"
android:src="@drawable/play_icon"
android:scaleType="centerInside"
android:contentDescription="@string/menu_play"/>
<LinearLayout android:layout_width="fill_parent"
android:layout_height="fill_parent"
@@ -54,7 +32,11 @@
android:layout_width="fill_parent"
android:layout_height="0dp"
android:layout_weight="2"
android:layout_gravity="center_vertical"/>
android:layout_gravity="center_vertical"
android:progressTint="@color/audio_icon"
android:progressTintMode="src_in"
android:thumbTint="@color/audio_icon"
android:thumbTintMode="src_in"/>
<LinearLayout android:layout_width="fill_parent"
android:layout_height="wrap_content"
@@ -37,7 +37,7 @@
app:minHeight="100dp"
app:maxHeight="300dp" />
<org.thoughtcrime.securesms.components.AudioView
<org.thoughtcrime.securesms.components.audioplay.AudioView
android:id="@+id/attachment_audio"
android:layout_width="230dp"
android:layout_height="wrap_content"
@@ -27,6 +27,7 @@
/>
<View
android:id="@+id/bottom_divider"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_gravity="bottom"
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.AudioView
<org.thoughtcrime.securesms.components.audioplay.AudioView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="210dp"
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.AudioView
<org.thoughtcrime.securesms.components.audioplay.AudioView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="210dp"
android:layout_height="wrap_content"
@@ -17,7 +17,7 @@
android:layout_weight="1"
android:visibility="gone"/>
<org.thoughtcrime.securesms.components.AudioView
<org.thoughtcrime.securesms.components.audioplay.AudioView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/audio_view"
+6
View File
@@ -386,8 +386,14 @@
<string name="answer_call">Answer</string>
<!-- the action "to decline" a call, not: "the decline" -->
<string name="end_call">Decline</string>
<!-- deprecated, use outgoing_audio_call or outgoing_video_call instead -->
<string name="outgoing_call">Outgoing call</string>
<string name="outgoing_audio_call">Outgoing audio call</string>
<string name="outgoing_video_call">Outgoing video call</string>
<!-- deprecated, use incoming_audio_call or incoming_video_call instead -->
<string name="incoming_call">Incoming call</string>
<string name="incoming_audio_call">Incoming audio call</string>
<string name="incoming_video_call">Incoming video call</string>
<string name="declined_call">Declined call</string>
<string name="canceled_call">Canceled call</string>
<string name="missed_call">Missed call</string>