diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index 639732224..43c323278 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -11,11 +11,11 @@ import androidx.annotation.NonNull; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; import org.thoughtcrime.securesms.util.concurrent.SettableFuture; -import org.whispersystems.libsignal.util.Pair; import java.io.IOException; import java.util.concurrent.ExecutorService; @@ -51,7 +51,7 @@ public class AudioRecorder { captureUri = BlobProvider.getInstance() .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) .withMimeType(MediaUtil.AUDIO_AAC) - .createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e)); + .createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e)); audioCodec = new AudioCodec(); audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); @@ -61,10 +61,10 @@ public class AudioRecorder { }); } - public @NonNull ListenableFuture> stopRecording() { + public @NonNull ListenableFuture stopRecording() { Log.i(TAG, "stopRecording()"); - final SettableFuture> future = new SettableFuture<>(); + final SettableFuture future = new SettableFuture<>(); executor.execute(() -> { if (audioCodec == null) { @@ -76,7 +76,7 @@ public class AudioRecorder { try { long size = MediaUtil.getMediaSize(context, captureUri); - sendToFuture(future, new Pair<>(captureUri, size)); + sendToFuture(future, new VoiceNoteDraft(captureUri, size)); } catch (IOException ioe) { Log.w(TAG, ioe); sendToFuture(future, ioe); diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java index 3e32b7e6e..b36618da0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveForm.java @@ -65,12 +65,6 @@ public final class AudioWaveForm { return; } - if (!(attachment instanceof DatabaseAttachment)) { - Log.i(TAG, "Not yet in database"); - ThreadUtil.runOnMain(onFailure); - return; - } - String cacheKey = uri.toString(); AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey); if (cached != null) { @@ -104,26 +98,46 @@ public final class AudioWaveForm { } } - try { - AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); - DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment; - long startTime = System.currentTimeMillis(); + if (attachment instanceof DatabaseAttachment) { + try { + AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment; + long startTime = System.currentTimeMillis(); - attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance()); + attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance()); - Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey)); + Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey)); - AudioFileInfo fileInfo = generateWaveForm(uri); + AudioFileInfo fileInfo = generateWaveForm(uri); - Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey)); + Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey)); - attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf()); + attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf()); - WAVE_FORM_CACHE.put(cacheKey, fileInfo); - ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo)); - } catch (Throwable e) { - Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e); - ThreadUtil.runOnMain(onFailure); + WAVE_FORM_CACHE.put(cacheKey, fileInfo); + ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo)); + } catch (Throwable e) { + Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e); + ThreadUtil.runOnMain(onFailure); + } + } else { + try { + Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly."); + + long startTime = System.currentTimeMillis(); + + Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey)); + + AudioFileInfo fileInfo = generateWaveForm(uri); + + Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey)); + + WAVE_FORM_CACHE.put(cacheKey, fileInfo); + ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo)); + } catch (IOException e) { + Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e); + ThreadUtil.runOnMain(onFailure); + } } }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java index 7f99d7e84..4793aec2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AudioView.java @@ -45,6 +45,10 @@ public final class AudioView extends FrameLayout { private static final String TAG = Log.tag(AudioView.class); + private static final int MODE_NORMAL = 0; + private static final int MODE_SMALL = 1; + private static final int MODE_DRAFT = 2; + private static final int FORWARDS = 1; private static final int REVERSE = -1; @@ -87,10 +91,23 @@ public final class AudioView extends FrameLayout { try { typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0); - smallView = typedArray.getBoolean(R.styleable.AudioView_small, false); + int mode = typedArray.getInteger(R.styleable.AudioView_audioView_mode, MODE_NORMAL); + smallView = mode == MODE_SMALL; autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false); - inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this); + switch (mode) { + case MODE_NORMAL: + inflate(context, R.layout.audio_view, this); + break; + case MODE_SMALL: + inflate(context, R.layout.audio_view_small, this); + break; + case MODE_DRAFT: + inflate(context, R.layout.audio_view_draft, this); + break; + default: + throw new IllegalStateException("Unsupported mode: " + mode); + } this.controlToggle = findViewById(R.id.control_toggle); this.playPauseButton = findViewById(R.id.play); @@ -280,7 +297,9 @@ public final class AudioView extends FrameLayout { } private void onSpeedChanged(@NonNull Uri uri, float speed) { - callbacks.onSpeedChanged(speed, isTarget(uri)); + if (callbacks != null) { + callbacks.onSpeedChanged(speed, isTarget(uri)); + } } private boolean isTarget(@NonNull Uri uri) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index 90ac88f27..6f92ce5d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -24,6 +24,7 @@ import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import androidx.lifecycle.Observer; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -34,7 +35,10 @@ import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.components.emoji.EmojiEventListener; import org.thoughtcrime.securesms.components.emoji.EmojiToggle; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter; +import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView; +import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.keyboard.KeyboardPage; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -83,6 +87,7 @@ public class InputPanel extends LinearLayout private SlideToCancel slideToCancel; private RecordTime recordTime; private ValueAnimator quoteAnimator; + private VoiceNoteDraftView voiceNoteDraftView; private @Nullable Listener listener; private boolean emojiVisible; @@ -118,6 +123,7 @@ public class InputPanel extends LinearLayout this.buttonToggle = findViewById(R.id.button_toggle); this.recordingContainer = findViewById(R.id.recording_container); this.recordLockCancel = findViewById(R.id.record_cancel); + this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view); this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel)); this.microphoneRecorderView = findViewById(R.id.recorder_view); this.microphoneRecorderView.setListener(this); @@ -154,6 +160,7 @@ public class InputPanel extends LinearLayout this.listener = listener; mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle()); + voiceNoteDraftView.setListener(listener); } public void setMediaListener(@NonNull MediaListener listener) { @@ -229,6 +236,10 @@ public class InputPanel extends LinearLayout return animator; } + public boolean hasSaveableContent() { + return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null; + } + public Optional getQuote() { if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) { return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions())); @@ -316,7 +327,10 @@ public class InputPanel extends LinearLayout recordTime.display(); slideToCancel.display(); - if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE); + if (emojiVisible) { + ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE); + } + ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE); ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE); ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE); @@ -369,6 +383,10 @@ public class InputPanel extends LinearLayout this.microphoneRecorderView.cancelAction(); } + public @NonNull Observer getPlaybackStateObserver() { + return voiceNoteDraftView.getPlaybackStateObserver(); + } + public void setEnabled(boolean enabled) { composeText.setEnabled(enabled); mediaKeyboard.setEnabled(enabled); @@ -385,11 +403,7 @@ public class InputPanel extends LinearLayout future.addListener(new AssertedSuccessListener() { @Override public void onSuccess(Void result) { - if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME); - ViewUtil.fadeIn(composeText, FADE_TIME); - ViewUtil.fadeIn(quickCameraToggle, FADE_TIME); - ViewUtil.fadeIn(quickAudioToggle, FADE_TIME); - buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start(); + fadeInNormalComposeViews(); } }); @@ -438,7 +452,41 @@ public class InputPanel extends LinearLayout .show(TooltipPopup.POSITION_ABOVE); } - public interface Listener { + public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) { + if (voiceNoteDraft != null) { + voiceNoteDraftView.setDraft(voiceNoteDraft); + voiceNoteDraftView.setVisibility(VISIBLE); + + if (emojiVisible) { + mediaKeyboard.setVisibility(View.INVISIBLE); + } + + composeText.setVisibility(View.INVISIBLE); + quickCameraToggle.setVisibility(View.INVISIBLE); + quickAudioToggle.setVisibility(View.INVISIBLE); + } else { + voiceNoteDraftView.clearDraft(); + ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME); + fadeInNormalComposeViews(); + } + } + + public @Nullable DraftDatabase.Draft getVoiceNoteDraft() { + return voiceNoteDraftView.getDraft(); + } + + private void fadeInNormalComposeViews() { + if (emojiVisible) { + ViewUtil.fadeIn(mediaKeyboard, FADE_TIME); + } + + ViewUtil.fadeIn(composeText, FADE_TIME); + ViewUtil.fadeIn(quickCameraToggle, FADE_TIME); + ViewUtil.fadeIn(quickAudioToggle, FADE_TIME); + buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start(); + } + + public interface Listener extends VoiceNoteDraftView.Listener { void onRecorderStarted(); void onRecorderLocked(); void onRecorderFinished(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteDraft.kt b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteDraft.kt new file mode 100644 index 000000000..5c16a85f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteDraft.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.components.voice + +import android.net.Uri +import org.thoughtcrime.securesms.database.DraftDatabase +import java.lang.IllegalArgumentException + +private const val SIZE = "size" + +class VoiceNoteDraft( + val uri: Uri, + val size: Long +) { + companion object { + @JvmStatic + fun fromDraft(draft: DraftDatabase.Draft): VoiceNoteDraft { + if (draft.type != DraftDatabase.Draft.VOICE_NOTE) { + throw IllegalArgumentException() + } + + val draftUri = Uri.parse(draft.value) + + val uri: Uri = draftUri.buildUpon().clearQuery().build() + val size: Long = draftUri.getQueryParameter("size")!!.toLong() + + return VoiceNoteDraft(uri, size) + } + } + + fun asDraft(): DraftDatabase.Draft { + val draftUri = uri.buildUpon().appendQueryParameter(SIZE, size.toString()) + + return DraftDatabase.Draft(DraftDatabase.Draft.VOICE_NOTE, draftUri.build().toString()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java index 763290381..05036ba0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaController.java @@ -35,6 +35,7 @@ import java.util.Objects; */ public class VoiceNoteMediaController implements DefaultLifecycleObserver { + public static final String EXTRA_THREAD_ID = "voice.note.thread_id"; public static final String EXTRA_MESSAGE_ID = "voice.note.message_id"; public static final String EXTRA_PROGRESS = "voice.note.playhead"; public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single"; @@ -99,11 +100,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) { - startPlayback(audioSlideUri, messageId, progress, false); + startPlayback(audioSlideUri, messageId, -1, progress, false); } public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) { - startPlayback(audioSlideUri, messageId, progress, true); + startPlayback(audioSlideUri, messageId, -1, progress, true); + } + + public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) { + startPlayback(draftUri, -1, threadId, progress, true); } /** @@ -115,7 +120,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { * @param progress The desired progress % to seek to. * @param singlePlayback The player will only play back the specified Uri, and not build a playlist. */ - private void startPlayback(@NonNull Uri audioSlideUri, long messageId, double progress, boolean singlePlayback) { + private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) { if (isCurrentTrack(audioSlideUri)) { long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION); @@ -124,6 +129,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver { } else { Bundle extras = new Bundle(); extras.putLong(EXTRA_MESSAGE_ID, messageId); + extras.putLong(EXTRA_THREAD_ID, threadId); extras.putDouble(EXTRA_PROGRESS, progress); extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java index 13e9a58aa..75a743a4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNoteMediaDescriptionCompatFactory.java @@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.DateUtils; -import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.Locale; import java.util.Objects; @@ -39,13 +38,33 @@ class VoiceNoteMediaDescriptionCompatFactory { private VoiceNoteMediaDescriptionCompatFactory() {} + static MediaDescriptionCompat buildMediaDescription(@NonNull Context context, + long threadId, + @NonNull Uri draftUri) + { + + Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + if (threadRecipient == null) { + threadRecipient = Recipient.UNKNOWN; + } + + return buildMediaDescription(context, + threadRecipient, + Recipient.self(), + Recipient.self(), + 0, + threadId, + -1, + System.currentTimeMillis(), + draftUri); + } + /** * Build out a MediaDescriptionCompat for a given voice note. Expects to be run * on a background thread. * * @param context Context. * @param messageRecord The MessageRecord of the given voice note. - * * @return A MediaDescriptionCompat with all the details the service expects. */ @WorkerThread @@ -60,15 +79,37 @@ class VoiceNoteMediaDescriptionCompatFactory { .getRecipientForThreadId(messageRecord.getThreadId())); Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient(); Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender; + Uri uri = Objects.requireNonNull(((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri()); + return buildMediaDescription(context, + threadRecipient, + avatarRecipient, + sender, + startingPosition, + messageRecord.getThreadId(), + messageRecord.getId(), + messageRecord.getDateReceived(), + uri); + } + + private static MediaDescriptionCompat buildMediaDescription(@NonNull Context context, + @NonNull Recipient threadRecipient, + @NonNull Recipient avatarRecipient, + @NonNull Recipient sender, + int startingPosition, + long threadId, + long messageId, + long dateReceived, + @NonNull Uri audioUri) + { Bundle extras = new Bundle(); extras.putString(EXTRA_THREAD_RECIPIENT_ID, threadRecipient.getId().serialize()); extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize()); extras.putString(EXTRA_INDIVIDUAL_RECIPIENT_ID, sender.getId().serialize()); extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition); - extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId()); + extras.putLong(EXTRA_THREAD_ID, threadId); extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor()); - extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId()); + extras.putLong(EXTRA_MESSAGE_ID, messageId); NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy(); @@ -87,13 +128,11 @@ class VoiceNoteMediaDescriptionCompatFactory { if (preference.isDisplayContact()) { subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message, DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), - messageRecord.getDateReceived())); + dateReceived)); } - Uri uri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri(); - return new MediaDescriptionCompat.Builder() - .setMediaUri(uri) + .setMediaUri(audioUri) .setTitle(title) .setSubtitle(subtitle) .setExtras(extras) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java index 2dd393d9c..dc3efe442 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackPreparer.java @@ -23,6 +23,8 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; @@ -95,6 +97,7 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP Log.d(TAG, "onPrepareFromUri: " + uri); long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID); + long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID); double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0); boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false); @@ -104,7 +107,11 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP SimpleTask.run(EXECUTOR, () -> { if (singlePlayback) { - return loadMediaDescriptionForSinglePlayback(messageId); + if (messageId != -1) { + return loadMediaDescriptionForSinglePlayback(messageId); + } else { + return loadMediaDescriptionForDraftPlayback(threadId, uri); + } } else { return loadMediaDescriptionsForConsecutivePlayback(messageId); } @@ -262,6 +269,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP } } + private @NonNull List loadMediaDescriptionForDraftPlayback(long threadId, @NonNull Uri draftUri) { + return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, threadId, draftUri)); + } + @WorkerThread private @NonNull List loadMediaDescriptionsForConsecutivePlayback(long messageId) { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index aaf2b4073..80087bfaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -76,6 +76,8 @@ import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.content.pm.ShortcutManagerCompat; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.graphics.drawable.IconCompat; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProviders; import com.annimon.stream.Collectors; @@ -130,6 +132,9 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView; import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity; +import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft; +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; @@ -139,6 +144,8 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState; import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; +import org.thoughtcrime.securesms.conversation.drafts.DraftRepository; +import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; @@ -402,6 +409,9 @@ public class ConversationActivity extends PassphraseRequiredActivity private MentionsPickerViewModel mentionsViewModel; private GroupCallViewModel groupCallViewModel; private VoiceRecorderWakeLock voiceRecorderWakeLock; + private DraftViewModel draftViewModel; + private VoiceNoteMediaController voiceNoteMediaController; + private LiveRecipient recipient; private long threadId; @@ -435,7 +445,8 @@ public class ConversationActivity extends PassphraseRequiredActivity return; } - voiceRecorderWakeLock = new VoiceRecorderWakeLock(this); + voiceNoteMediaController = new VoiceNoteMediaController(this); + voiceRecorderWakeLock = new VoiceRecorderWakeLock(this); new FullscreenHelper(this).showSystemUI(); @@ -462,6 +473,7 @@ public class ConversationActivity extends PassphraseRequiredActivity initializeGroupViewModel(); initializeMentionsViewModel(); initializeGroupCallViewModel(); + initializeDraftViewModel(); initializeEnabledCheck(); initializePendingRequestsBanner(); initializeGroupV1MigrationsBanners(); @@ -520,7 +532,7 @@ public class ConversationActivity extends PassphraseRequiredActivity } reactWithAnyEmojiStartPage = -1; - if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.getQuote().isPresent()) { + if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent() || inputPanel.hasSaveableContent()) { saveDraft(); attachmentManager.clear(glideRequests, false); inputPanel.clearQuote(); @@ -627,6 +639,7 @@ public class ConversationActivity extends PassphraseRequiredActivity @Override protected void onStop() { super.onStop(); + saveDraft(); EventBus.getDefault().unregister(this); } @@ -647,7 +660,6 @@ public class ConversationActivity extends PassphraseRequiredActivity @Override protected void onDestroy() { - saveDraft(); if (securityUpdateReceiver != null) unregisterReceiver(securityUpdateReceiver); super.onDestroy(); } @@ -1761,6 +1773,9 @@ public class ConversationActivity extends PassphraseRequiredActivity SettableFuture quoteResult = new SettableFuture<>(); new QuoteRestorationTask(draft.getValue(), quoteResult).execute(); quoteResult.addListener(listener); + case Draft.VOICE_NOTE: + draftViewModel.setVoiceNoteDraft(recipient.getId(), draft); + voiceNoteMediaController.getVoiceNotePlaybackState().observe(ConversationActivity.this, inputPanel.getPlaybackStateObserver()); break; } } catch (IOException e) { @@ -2277,6 +2292,20 @@ public class ConversationActivity extends PassphraseRequiredActivity groupCallViewModel.groupCallHasCapacity().observe(this, hasCapacity -> joinGroupCallButton.setText(hasCapacity ? R.string.ConversationActivity_join : R.string.ConversationActivity_full)); } + public void initializeDraftViewModel() { + draftViewModel = ViewModelProviders.of(this, new DraftViewModel.Factory(new DraftRepository(getApplicationContext()))).get(DraftViewModel.class); + + recipient.observe(this, r -> { + draftViewModel.onRecipientChanged(r); + }); + + draftViewModel.getState().observe(this, + state -> { + inputPanel.setVoiceNoteDraft(state.getVoiceNoteDraft()); + updateToggleButtonState(); + }); + } + private void showGroupCallingTooltip() { if (Build.VERSION.SDK_INT == 19 || !SignalStore.tooltips().shouldShowGroupCallingTooltip() || callingTooltipShown) { return; @@ -2416,6 +2445,10 @@ public class ConversationActivity extends PassphraseRequiredActivity groupCallViewModel.onRecipientChange(recipient); } + if (draftViewModel != null) { + draftViewModel.onRecipientChanged(recipient); + } + if (this.threadId == -1) { SimpleTask.run(() -> DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()), threadId -> { if (this.threadId != threadId) { @@ -2562,6 +2595,11 @@ public class ConversationActivity extends PassphraseRequiredActivity drafts.add(new Draft(Draft.QUOTE, new QuoteId(quote.get().getId(), quote.get().getAuthor()).serialize())); } + DraftDatabase.Draft voiceNoteDraft = draftViewModel.getVoiceNoteDraft(); + if (voiceNoteDraft != null) { + drafts.add(voiceNoteDraft); + } + return drafts; } @@ -2573,13 +2611,25 @@ public class ConversationActivity extends PassphraseRequiredActivity return future; } - final Drafts drafts = getDraftsForCurrentState(); - final long thisThreadId = this.threadId; - final int thisDistributionType = this.distributionType; + final Drafts drafts = getDraftsForCurrentState(); + final long thisThreadId = this.threadId; + final RecipientId recipientId = this.recipient.getId(); + final int thisDistributionType = this.distributionType; + final ListenableFuture voiceNoteDraftFuture = draftViewModel.consumeVoiceNoteDraftFuture(); new AsyncTask() { @Override protected Long doInBackground(Long... params) { + if (voiceNoteDraftFuture != null) { + try { + Draft voiceNoteDraft = voiceNoteDraftFuture.get().asDraft(); + draftViewModel.setVoiceNoteDraft(recipientId, voiceNoteDraft); + drafts.add(voiceNoteDraft); + } catch (ExecutionException | InterruptedException e) { + Log.w(TAG, "Could not extract voice note draft data.", e); + } + } + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(ConversationActivity.this); DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this); long threadId = params[0]; @@ -2587,7 +2637,7 @@ public class ConversationActivity extends PassphraseRequiredActivity if (drafts.size() > 0) { if (threadId == -1) threadId = threadDatabase.getThreadIdFor(getRecipient(), thisDistributionType); - draftDatabase.insertDrafts(threadId, drafts); + draftDatabase.replaceDrafts(threadId, drafts); threadDatabase.updateSnippet(threadId, drafts.getSnippet(ConversationActivity.this), drafts.getUriSnippet(), System.currentTimeMillis(), Types.BASE_DRAFT_TYPE, true); @@ -2761,6 +2811,15 @@ public class ConversationActivity extends PassphraseRequiredActivity return; } + Draft voiceNote = draftViewModel.getVoiceNoteDraft(); + if (voiceNote != null) { + AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(this, voiceNote); + + sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize()); + draftViewModel.clearVoiceNoteDraft(); + return; + } + try { Recipient recipient = getRecipient(); @@ -2975,6 +3034,13 @@ public class ConversationActivity extends PassphraseRequiredActivity return; } + if (draftViewModel.hasVoiceNoteDraft()) { + buttonToggle.display(sendButton); + quickAttachmentToggle.hide(); + inlineAttachmentToggle.hide(); + return; + } + if (composeText.getText().length() == 0 && !attachmentManager.isAttachmentPresent()) { buttonToggle.display(attachButton); quickAttachmentToggle.show(); @@ -3063,44 +3129,11 @@ public class ConversationActivity extends PassphraseRequiredActivity getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - ListenableFuture> future = audioRecorder.stopRecording(); - future.addListener(new ListenableFuture.Listener>() { + ListenableFuture future = audioRecorder.stopRecording(); + future.addListener(new ListenableFuture.Listener() { @Override - public void onSuccess(final @NonNull Pair result) { - boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms(); - boolean initiating = threadId == -1; - int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); - long expiresIn = recipient.get().getExpireMessages() * 1000L; - AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first(), result.second(), MediaUtil.AUDIO_AAC, true); - SlideDeck slideDeck = new SlideDeck(); - slideDeck.addSlide(audioSlide); - - ListenableFuture sendResult = sendMediaMessage(recipient.getId(), - forceSms, - "", - slideDeck, - inputPanel.getQuote().orNull(), - Collections.emptyList(), - Collections.emptyList(), - composeText.getMentions(), - expiresIn, - false, - subscriptionId, - initiating, - true); - - sendResult.addListener(new AssertedSuccessListener() { - @Override - public void onSuccess(Void nothing) { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - BlobProvider.getInstance().delete(ConversationActivity.this, result.first()); - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - }); + public void onSuccess(final @NonNull VoiceNoteDraft result) { + sendVoiceNote(result.getUri(), result.getSize()); } @Override @@ -3120,22 +3153,12 @@ public class ConversationActivity extends PassphraseRequiredActivity getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - ListenableFuture> future = audioRecorder.stopRecording(); - future.addListener(new ListenableFuture.Listener>() { - @Override - public void onSuccess(final Pair result) { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - BlobProvider.getInstance().delete(ConversationActivity.this, result.first()); - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - @Override - public void onFailure(ExecutionException e) {} - }); + ListenableFuture future = audioRecorder.stopRecording(); + if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { + future.addListener(new DeleteCanceledVoiceNoteListener()); + } else { + draftViewModel.setVoiceNoteDraftFuture(future); + } } @Override @@ -3194,6 +3217,37 @@ public class ConversationActivity extends PassphraseRequiredActivity container.hideAttachedInput(true); } + private void sendVoiceNote(@NonNull Uri uri, long size) { + boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms(); + boolean initiating = threadId == -1; + int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + long expiresIn = recipient.get().getExpireMessages() * 1000L; + AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, uri, size, MediaUtil.AUDIO_AAC, true); + SlideDeck slideDeck = new SlideDeck(); + slideDeck.addSlide(audioSlide); + + ListenableFuture sendResult = sendMediaMessage(recipient.getId(), + forceSms, + "", + slideDeck, + inputPanel.getQuote().orNull(), + Collections.emptyList(), + Collections.emptyList(), + composeText.getMentions(), + expiresIn, + false, + subscriptionId, + initiating, + true); + + sendResult.addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Void nothing) { + draftViewModel.deleteBlob(uri); + } + }); + } + private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) { sendSticker(new StickerLocator(stickerRecord.getPackId(), stickerRecord.getPackKey(), stickerRecord.getStickerId(), stickerRecord.getEmoji()), stickerRecord.getContentType(), stickerRecord.getUri(), stickerRecord.getSize(), clearCompose); @@ -3297,8 +3351,39 @@ public class ConversationActivity extends PassphraseRequiredActivity } } + @Override + public void onVoiceNoteDraftPlay(@NonNull Uri audioUri, double progress) { + voiceNoteMediaController.startSinglePlaybackForDraft(audioUri, threadId, progress); + } + + @Override + public void onVoiceNoteDraftPause(@NonNull Uri audioUri) { + voiceNoteMediaController.pausePlayback(audioUri); + } + + @Override + public void onVoiceNoteDraftSeekTo(@NonNull Uri audioUri, double progress) { + voiceNoteMediaController.seekToPosition(audioUri, progress); + } + + @Override + public void onVoiceNoteDraftDelete(@NonNull Uri audioUri) { + voiceNoteMediaController.stopPlaybackAndReset(audioUri); + draftViewModel.deleteVoiceNoteDraft(); + } + // Listeners + private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener { + @Override + public void onSuccess(final VoiceNoteDraft result) { + draftViewModel.deleteBlob(result.getUri()); + } + + @Override + public void onFailure(ExecutionException e) {} + } + private class QuickCameraToggleListener implements OnClickListener { @Override public void onClick(View v) { @@ -3563,6 +3648,36 @@ public class ConversationActivity extends PassphraseRequiredActivity reactionDelegate.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight()); } + @Override + public void onVoiceNotePause(@NonNull Uri uri) { + voiceNoteMediaController.pausePlayback(uri); + } + + @Override + public void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress) { + voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress); + } + + @Override + public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) { + voiceNoteMediaController.seekToPosition(uri, progress); + } + + @Override + public void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed) { + voiceNoteMediaController.setPlaybackSpeed(uri, speed); + } + + @Override + public void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { + voiceNoteMediaController.getVoiceNotePlaybackState().observe(this, onPlaybackStartObserver); + } + + @Override + public void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { + voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver); + } + @Override public void onCursorChanged() { if (!reactionDelegate.isShowing()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 65d9cdf52..c81e6869e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -81,7 +81,6 @@ import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.TypingStatusRepository; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; -import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactUtil; @@ -214,7 +213,6 @@ public class ConversationFragment extends LoggingFragment { private Animation mentionButtonOutAnimation; private OnScrollListener conversationScrollListener; private int pulsePosition = -1; - private VoiceNoteMediaController voiceNoteMediaController; private View toolbarShadow; private ColorizerView colorizerView; private Stopwatch startupStopwatch; @@ -408,7 +406,6 @@ public class ConversationFragment extends LoggingFragment { initializeResources(); initializeMessageRequestViewModel(); initializeListAdapter(); - voiceNoteMediaController = new VoiceNoteMediaController((AppCompatActivity) requireActivity()); } @Override @@ -1305,6 +1302,12 @@ public class ConversationFragment extends LoggingFragment { void onListVerticalTranslationChanged(float translationY); void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); void handleReactionDetails(@NonNull MaskView.MaskTarget maskTarget); + void onVoiceNotePause(@NonNull Uri uri); + void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress); + void onVoiceNoteSeekTo(@NonNull Uri uri, double progress); + void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed); + void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); + void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); } private class ConversationScrollListener extends OnScrollListener { @@ -1581,32 +1584,32 @@ public class ConversationFragment extends LoggingFragment { @Override public void onVoiceNotePause(@NonNull Uri uri) { - voiceNoteMediaController.pausePlayback(uri); + listener.onVoiceNotePause(uri); } @Override public void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress) { - voiceNoteMediaController.startConsecutivePlayback(uri, messageId, progress); + listener.onVoiceNotePlay(uri, messageId, progress); } @Override public void onVoiceNoteSeekTo(@NonNull Uri uri, double progress) { - voiceNoteMediaController.seekToPosition(uri, progress); + listener.onVoiceNoteSeekTo(uri, progress); } @Override public void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed) { - voiceNoteMediaController.setPlaybackSpeed(uri, speed); + listener.onVoiceNotePlaybackSpeedChanged(uri, speed); } @Override public void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { - voiceNoteMediaController.getVoiceNotePlaybackState().observe(getViewLifecycleOwner(), onPlaybackStartObserver); + listener.onRegisterVoiceNoteCallbacks(onPlaybackStartObserver); } @Override public void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver) { - voiceNoteMediaController.getVoiceNotePlaybackState().removeObserver(onPlaybackStartObserver); + listener.onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/VoiceNoteDraftView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/VoiceNoteDraftView.kt new file mode 100644 index 000000000..ff4d60e13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/VoiceNoteDraftView.kt @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.conversation + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.View +import androidx.appcompat.widget.LinearLayoutCompat +import androidx.lifecycle.Observer +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AudioView +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.mms.AudioSlide + +class VoiceNoteDraftView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayoutCompat(context, attrs, defStyleAttr) { + + var listener: Listener? = null + + var draft: DraftDatabase.Draft? = null + private set + + private lateinit var audioView: AudioView + + val playbackStateObserver: Observer + get() = audioView.playbackStateObserver + + init { + inflate(context, R.layout.voice_note_draft_view, this) + + val delete: View = findViewById(R.id.voice_note_draft_delete) + + delete.setOnClickListener { + if (draft != null) { + val uri = audioView.audioSlideUri + if (uri != null) { + listener?.onVoiceNoteDraftDelete(uri) + } + } + } + + audioView = findViewById(R.id.voice_note_audio_view) + } + + fun clearDraft() { + this.draft = null + } + + fun setDraft(draft: DraftDatabase.Draft) { + audioView.setAudio( + AudioSlide.createFromVoiceNoteDraft(context, draft), + AudioViewCallbacksAdapter(), + true, + false + ) + + this.draft = draft + } + + private inner class AudioViewCallbacksAdapter : AudioView.Callbacks { + override fun onPlay(audioUri: Uri, progress: Double) { + listener?.onVoiceNoteDraftPlay(audioUri, progress) + } + + override fun onPause(audioUri: Uri) { + listener?.onVoiceNoteDraftPause(audioUri) + } + + override fun onSeekTo(audioUri: Uri, progress: Double) { + listener?.onVoiceNoteDraftSeekTo(audioUri, progress) + } + + override fun onStopAndReset(audioUri: Uri) { + throw UnsupportedOperationException() + } + + override fun onProgressUpdated(durationMillis: Long, playheadMillis: Long) = Unit + + override fun onSpeedChanged(speed: Float, isPlaying: Boolean) = Unit + } + + interface Listener { + fun onVoiceNoteDraftPlay(audioUri: Uri, progress: Double) + fun onVoiceNoteDraftPause(audioUri: Uri) + fun onVoiceNoteDraftSeekTo(audioUri: Uri, progress: Double) + fun onVoiceNoteDraftDelete(audioUri: Uri) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt new file mode 100644 index 000000000..1aae8857e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.conversation.drafts + +import android.content.Context +import android.net.Uri +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.providers.BlobProvider + +class DraftRepository(private val context: Context) { + fun deleteVoiceNoteDraft(draft: DraftDatabase.Draft) { + deleteBlob(Uri.parse(draft.value).buildUpon().clearQuery().build()) + } + + fun deleteBlob(uri: Uri) { + SignalExecutors.BOUNDED.execute { + BlobProvider.getInstance().delete(context, uri) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt new file mode 100644 index 000000000..fe8dc14ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.conversation.drafts + +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * State object responsible for holding Voice Note draft state. The intention is to allow + * other pieces of draft state to be held here as well in the future, and to serve as a + * management pattern going forward for drafts. + */ +data class DraftState( + val recipientId: RecipientId = Recipient.UNKNOWN.id, + val voiceNoteDraft: DraftDatabase.Draft? = null +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt new file mode 100644 index 000000000..36c4ce63d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.conversation.drafts + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture +import org.thoughtcrime.securesms.util.livedata.Store + +/** + * ViewModel responsible for holding Voice Note draft state. The intention is to allow + * other pieces of draft state to be held here as well in the future, and to serve as a + * management pattern going forward for drafts. + */ +class DraftViewModel( + private val repository: DraftRepository +) : ViewModel() { + + private val store = Store(DraftState()) + + val state: LiveData = store.stateLiveData + + private var voiceNoteDraftFuture: ListenableFuture? = null + + val voiceNoteDraft: DraftDatabase.Draft? + get() = store.state.voiceNoteDraft + + fun consumeVoiceNoteDraftFuture(): ListenableFuture? { + val future = voiceNoteDraftFuture + voiceNoteDraftFuture = null + + return future + } + + fun setVoiceNoteDraftFuture(voiceNoteDraftFuture: ListenableFuture) { + this.voiceNoteDraftFuture = voiceNoteDraftFuture + } + + fun setVoiceNoteDraft(recipientId: RecipientId, draft: DraftDatabase.Draft) { + store.update { + it.copy(recipientId = recipientId, voiceNoteDraft = draft) + } + } + + @get:JvmName("hasVoiceNoteDraft") + val hasVoiceNoteDraft: Boolean + get() = store.state.voiceNoteDraft != null + + fun clearVoiceNoteDraft() { + store.update { + it.copy(voiceNoteDraft = null) + } + } + + fun deleteVoiceNoteDraft() { + val draft = store.state.voiceNoteDraft + if (draft != null) { + clearVoiceNoteDraft() + repository.deleteVoiceNoteDraft(draft) + } + } + + fun onRecipientChanged(recipient: Recipient) { + store.update { + if (recipient.id != it.recipientId) { + it.copy(recipientId = recipient.id, voiceNoteDraft = null) + } else { + it + } + } + } + + fun deleteBlob(uri: Uri) { + repository.deleteBlob(uri) + } + + class Factory(private val repository: DraftRepository) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(DraftViewModel(repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java index df9b5128b..adcc60d2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -10,6 +10,8 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.SqlUtil; import java.util.LinkedList; import java.util.List; @@ -34,16 +36,26 @@ public class DraftDatabase extends Database { super(context, databaseHelper); } - public void insertDrafts(long threadId, List drafts) { + public void replaceDrafts(long threadId, List drafts) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); - for (Draft draft : drafts) { - ContentValues values = new ContentValues(3); - values.put(THREAD_ID, threadId); - values.put(DRAFT_TYPE, draft.getType()); - values.put(DRAFT_VALUE, draft.getValue()); + try { + db.beginTransaction(); - db.insert(TABLE_NAME, null, values); + db.delete(TABLE_NAME, THREAD_ID + " = ?", SqlUtil.buildArgs(threadId)); + + for (Draft draft : drafts) { + ContentValues values = new ContentValues(3); + values.put(THREAD_ID, threadId); + values.put(DRAFT_TYPE, draft.getType()); + values.put(DRAFT_VALUE, draft.getValue()); + + db.insert(TABLE_NAME, null, values); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); } } @@ -89,14 +101,33 @@ public class DraftDatabase extends Database { } } + public @NonNull Drafts getAllVoiceNoteDrafts() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Drafts results = new Drafts(); + String where = DRAFT_TYPE + " = ?"; + String[] args = SqlUtil.buildArgs(Draft.VOICE_NOTE); + + try (Cursor cursor = db.query(TABLE_NAME, null, where, args, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + String type = CursorUtil.requireString(cursor, DRAFT_TYPE); + String value = CursorUtil.requireString(cursor, DRAFT_VALUE); + + results.add(new Draft(type, value)); + } + + return results; + } + } + public static class Draft { - public static final String TEXT = "text"; - public static final String IMAGE = "image"; - public static final String VIDEO = "video"; - public static final String AUDIO = "audio"; - public static final String LOCATION = "location"; - public static final String QUOTE = "quote"; - public static final String MENTION = "mention"; + public static final String TEXT = "text"; + public static final String IMAGE = "image"; + public static final String VIDEO = "video"; + public static final String AUDIO = "audio"; + public static final String LOCATION = "location"; + public static final String QUOTE = "quote"; + public static final String MENTION = "mention"; + public static final String VOICE_NOTE = "voice_note"; private final String type; private final String value; @@ -116,13 +147,14 @@ public class DraftDatabase extends Database { String getSnippet(Context context) { switch (type) { - case TEXT: return value; - case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet); - case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet); - case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet); - case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet); - case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet); - default: return null; + case TEXT: return value; + case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet); + case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet); + case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet); + case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet); + case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet); + case VOICE_NOTE: return context.getString(R.string.DraftDatabase_Draft_voice_note); + default: return null; } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java index 3411cef5c..4661e3a09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -26,12 +26,39 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft; import org.thoughtcrime.securesms.database.AttachmentDatabase; +import org.thoughtcrime.securesms.database.DraftDatabase; +import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; +import java.util.Objects; + public class AudioSlide extends Slide { + public static @NonNull AudioSlide createFromVoiceNoteDraft(@NonNull Context context, @NonNull DraftDatabase.Draft draft) { + VoiceNoteDraft voiceNoteDraft = VoiceNoteDraft.fromDraft(draft); + + return new AudioSlide(context, new UriAttachment(voiceNoteDraft.getUri(), + MediaUtil.AUDIO_AAC, + AttachmentDatabase.TRANSFER_PROGRESS_DONE, + voiceNoteDraft.getSize(), + 0, + 0, + null, + null, + true, + false, + false, + false, + null, + null, + null, + null, + null)); + } + public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) { super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, null, null, null, voiceNote, false, false, false)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index 794544b6d..56f2ff120 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -17,10 +17,13 @@ import org.signal.core.util.StreamUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft; import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.util.IOFunction; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.video.ByteArrayMediaDataSource; @@ -32,10 +35,13 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; /** * Allows for the creation and retrieval of blobs. @@ -44,8 +50,9 @@ public class BlobProvider { private static final String TAG = Log.tag(BlobProvider.class); - private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs"; - private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs"; + private static final String DRAFT_ATTACHMENTS_DIRECTORY = "draft_blobs"; + private static final String MULTI_SESSION_DIRECTORY = "multi_session_blobs"; + private static final String SINGLE_SESSION_DIRECTORY = "single_session_blobs"; public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".blob"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/blob"); @@ -219,6 +226,8 @@ public class BlobProvider { Log.w(TAG, "Null directory listing!"); } + deleteOrphanedDraftFiles(context); + Log.i(TAG, "Initialized."); initialized = true; notifyAll(); @@ -226,6 +235,38 @@ public class BlobProvider { }); } + private static void deleteOrphanedDraftFiles(@NonNull Context context) { + File directory = getOrCreateDirectory(context, DRAFT_ATTACHMENTS_DIRECTORY); + File[] files = directory.listFiles(); + + if (files == null || files.length == 0) { + Log.d(TAG, "No attachment drafts exist. Skipping."); + return; + } + + DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(context); + DraftDatabase.Drafts voiceNoteDrafts = draftDatabase.getAllVoiceNoteDrafts(); + + @SuppressWarnings("ConstantConditions") + List draftFileNames = voiceNoteDrafts.stream() + .map(VoiceNoteDraft::fromDraft) + .map(VoiceNoteDraft::getUri) + .map(BlobProvider::getId) + .filter(Objects::nonNull) + .map(BlobProvider::buildFileName) + .collect(Collectors.toList()); + + for (final File file : files) { + if (!draftFileNames.contains(file.getName())) { + if (file.delete()) { + Log.d(TAG, "Deleted orphaned attachment draft: " + file.getName()); + } else { + Log.d(TAG, "Failed to delete orphaned attachment draft: " + file.getName()); + } + } + } + } + public static @Nullable String getMimeType(@NonNull Uri uri) { if (isAuthority(uri)) { return uri.getPathSegments().get(MIMETYPE_PATH_SEGMENT); @@ -344,6 +385,17 @@ public class BlobProvider { } private static @NonNull String getDirectory(@NonNull StorageType storageType) { + switch (storageType) { + case SINGLE_USE_MEMORY: + case SINGLE_SESSION_MEMORY: + throw new IllegalArgumentException("In-Memory Blobs do not have directories."); + case SINGLE_SESSION_DISK: + return SINGLE_SESSION_DIRECTORY; + case MULTI_SESSION_DISK: + return MULTI_SESSION_DIRECTORY; + case ATTACHMENT_DRAFT: + return DRAFT_ATTACHMENTS_DIRECTORY; + } return storageType == StorageType.MULTI_SESSION_DISK ? MULTI_SESSION_DIRECTORY : SINGLE_SESSION_DIRECTORY; } @@ -417,6 +469,7 @@ public class BlobProvider { * Create a blob that will exist for multiple app sessions. It is the caller's responsibility to * eventually call {@link BlobProvider#delete(Context, Uri)} when the blob is no longer in use. */ + @Deprecated @WorkerThread public Uri createForMultipleSessionsOnDisk(@NonNull Context context) throws IOException { return writeBlobSpecToDisk(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK)); @@ -431,12 +484,12 @@ public class BlobProvider { * when the blob is no longer in use. */ @WorkerThread - public Uri createForMultipleSessionsOnDiskAsync(@NonNull Context context, - @Nullable SuccessListener successListener, - @Nullable ErrorListener errorListener) + public Uri createForDraftAttachmentAsync(@NonNull Context context, + @Nullable SuccessListener successListener, + @Nullable ErrorListener errorListener) throws IOException { - return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.MULTI_SESSION_DISK), successListener, errorListener); + return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.ATTACHMENT_DRAFT), successListener, errorListener); } } @@ -555,7 +608,8 @@ public class BlobProvider { SINGLE_USE_MEMORY("single-use-memory", true), SINGLE_SESSION_MEMORY("single-session-memory", true), SINGLE_SESSION_DISK("single-session-disk", false), - MULTI_SESSION_DISK("multi-session-disk", false); + MULTI_SESSION_DISK("multi-session-disk", false), + ATTACHMENT_DRAFT("attachment-draft", false); private final String encoded; private final boolean inMemory; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java index 8bc090ccb..b45fb38d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/BlobDataSource.java @@ -47,6 +47,10 @@ public class BlobDataSource implements DataSource { } long size = unwrapLong(BlobProvider.getFileSize(uri)); + if (size == 0) { + size = BlobProvider.getInstance().calculateFileSize(context, uri); + } + if (size - dataSpec.position <= 0) throw new EOFException("No more data"); return size - dataSpec.position; diff --git a/app/src/main/res/drawable-night/ic_trash_24.xml b/app/src/main/res/drawable-night/ic_trash_24.xml index d53105805..d8f683605 100644 --- a/app/src/main/res/drawable-night/ic_trash_24.xml +++ b/app/src/main/res/drawable-night/ic_trash_24.xml @@ -1,5 +1,9 @@ - - + + diff --git a/app/src/main/res/drawable-night/ic_trash_filled_24.xml b/app/src/main/res/drawable-night/ic_trash_filled_24.xml new file mode 100644 index 000000000..d8f683605 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_trash_filled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trash_24.xml b/app/src/main/res/drawable/ic_trash_24.xml index f297fee28..ef86e8190 100644 --- a/app/src/main/res/drawable/ic_trash_24.xml +++ b/app/src/main/res/drawable/ic_trash_24.xml @@ -1,5 +1,9 @@ - - + + diff --git a/app/src/main/res/drawable/ic_trash_filled_24.xml b/app/src/main/res/drawable/ic_trash_filled_24.xml new file mode 100644 index 000000000..d8f683605 --- /dev/null +++ b/app/src/main/res/drawable/ic_trash_filled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/audio_view_draft.xml b/app/src/main/res/layout/audio_view_draft.xml new file mode 100644 index 000000000..8050d5fe4 --- /dev/null +++ b/app/src/main/res/layout/audio_view_draft.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/audio_view_draft_circle.xml b/app/src/main/res/layout/audio_view_draft_circle.xml new file mode 100644 index 000000000..ecba2d9ef --- /dev/null +++ b/app/src/main/res/layout/audio_view_draft_circle.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_input_panel.xml b/app/src/main/res/layout/conversation_input_panel.xml index cc0ecbb59..09fd0035c 100644 --- a/app/src/main/res/layout/conversation_input_panel.xml +++ b/app/src/main/res/layout/conversation_input_panel.xml @@ -1,15 +1,14 @@ - + android:clipToPadding="false" + android:orientation="vertical"> + android:paddingEnd="6dp" + android:tint="@color/signal_icon_tint_primary" /> @@ -164,8 +163,8 @@ android:id="@+id/inline_attachment_button" android:layout_width="24dp" android:layout_height="@dimen/conversation_compose_height" - android:layout_marginEnd="8dp" android:layout_gravity="bottom" + android:layout_marginEnd="8dp" android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/ConversationActivity_add_attachment" android:scaleType="fitCenter" @@ -180,6 +179,13 @@ + + diff --git a/app/src/main/res/layout/media_overview_detail_item_audio.xml b/app/src/main/res/layout/media_overview_detail_item_audio.xml index d64f27d38..301c88937 100644 --- a/app/src/main/res/layout/media_overview_detail_item_audio.xml +++ b/app/src/main/res/layout/media_overview_detail_item_audio.xml @@ -20,7 +20,7 @@ app:autoRewind="true" app:foregroundTintColor="@color/core_ultramarine" app:progressAndPlayTint="@android:color/transparent" - app:small="true" /> + app:audioView_mode="small" /> diff --git a/app/src/main/res/layout/voice_note_draft_view.xml b/app/src/main/res/layout/voice_note_draft_view.xml new file mode 100644 index 000000000..77393b9db --- /dev/null +++ b/app/src/main/res/layout/voice_note_draft_view.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 44f7296e8..e9920641f 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -72,8 +72,12 @@ - + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 15810b46c..c9f41a0c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,7 @@ (video) (location) (reply) + (Voice message) Gallery