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 cbfbc427fd..e8b6079a89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -35,9 +35,9 @@ public class AudioRecorder { private final AudioRecordingHandler uiHandler; private final AudioRecorderFocusManager audioFocusManager; - private Recorder recorder; - private Future recordingUriFuture; - + private Recorder recorder; + private Future recordingUriFuture; + private volatile Uri recordingUri; private SingleSubject recordingSubject; public AudioRecorder(@NonNull Context context, @Nullable AudioRecordingHandler uiHandler) { @@ -88,10 +88,12 @@ public class AudioRecorder { ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe(); - recordingUriFuture = BlobProvider.getInstance() - .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) - .withMimeType(MediaUtil.AUDIO_AAC) - .createForDraftAttachmentAsync(context); + BlobProvider.BlobBuilder blobBuilder = BlobProvider.getInstance() + .forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0) + .withMimeType(MediaUtil.AUDIO_AAC); + + recordingUri = blobBuilder.buildUriForDraftAttachment(); + recordingUriFuture = blobBuilder.createForDraftAttachmentAsync(context); recorder = useMediaRecorderWrapper ? new MediaRecorderWrapper() : new AudioCodec(); int focusResult = audioFocusManager.requestAudioFocus(); @@ -130,6 +132,7 @@ public class AudioRecorder { recordingSubject = null; recorder = null; recordingUriFuture = null; + recordingUri = null; }); } @@ -157,6 +160,27 @@ public class AudioRecorder { recordingSubject = null; recorder = null; recordingUriFuture = null; + recordingUri = null; }); } + + /** + * Gets a snapshot of the current recording as a VoiceNoteDraft, without stopping the recording. + * This can be used to periodically save drafts while recording is in progress. + * Returns null if there is no active recording. + */ + @Nullable + public VoiceNoteDraft getCurrentRecordingSnapshot() { + if (recordingUri == null) { + return null; + } + + try { + long size = MediaUtil.getMediaSize(context, recordingUri); + return new VoiceNoteDraft(recordingUri, size); + } catch (IOException e) { + Log.w(TAG, "Error getting current recording snapshot", e); + return 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 index 0418ff14a1..a706e53ff9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt @@ -44,6 +44,9 @@ class DraftViewModel @JvmOverloads constructor( fun cancelEphemeralVoiceNoteDraft(draft: Draft) { repository.deleteVoiceNoteDraftData(draft) + store.update { + saveDraftsIfChanged(it, it.copy(voiceNoteDraft = null)) + } } fun deleteVoiceNoteDraft() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 42831fce10..fe40a0b384 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -1238,7 +1238,9 @@ class ConversationFragment : .state .distinctUntilChanged { previous, next -> previous.voiceNoteDraft == next.voiceNoteDraft } .subscribe { - inputPanel.voiceNoteDraft = it.voiceNoteDraft + if (!voiceMessageRecordingDelegate.hasActiveSession()) { + inputPanel.voiceNoteDraft = it.voiceNoteDraft + } updateToggleButtonState() } ) @@ -4377,9 +4379,6 @@ class ConversationFragment : } override fun onRecorderCanceled(byUser: Boolean) { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { - updateToggleButtonState() - } voiceMessageRecordingDelegate.onRecorderCanceled(byUser) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/VoiceMessageRecordingDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/VoiceMessageRecordingDelegate.kt index c001ee56e8..c5925630ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/VoiceMessageRecordingDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/VoiceMessageRecordingDelegate.kt @@ -10,9 +10,12 @@ import android.view.WindowManager import android.widget.Toast import androidx.fragment.app.Fragment import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.SingleObserver +import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.addTo import org.signal.core.util.logging.Log @@ -21,6 +24,7 @@ import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft import org.thoughtcrime.securesms.conversation.VoiceRecorderWakeLock import org.thoughtcrime.securesms.util.ServiceUtil +import java.util.concurrent.TimeUnit /** * Delegate class for VoiceMessage recording. @@ -33,6 +37,7 @@ class VoiceMessageRecordingDelegate( companion object { private val TAG = Log.tag(VoiceMessageRecordingDelegate::class.java) + private const val PERIODIC_DRAFT_SAVE_INTERVAL_MS = 1000L } private val disposables = LifecycleDisposable().apply { @@ -43,6 +48,8 @@ class VoiceMessageRecordingDelegate( private var session: Session? = null + fun hasActiveSession(): Boolean = session != null + fun onRecorderStarted() { beginRecording() } @@ -115,16 +122,22 @@ class VoiceMessageRecordingDelegate( private var saveDraft = true private var shouldSend = false - private var disposable = Disposable.empty() + private val compositeDisposable = CompositeDisposable() init { observable .observeOn(AndroidSchedulers.mainThread()) .subscribe(this) + + Flowable.interval(PERIODIC_DRAFT_SAVE_INTERVAL_MS, TimeUnit.MILLISECONDS) + .onBackpressureDrop() + .observeOn(Schedulers.single()) + .subscribe { savePeriodicDraftSnapshot() } + .let { compositeDisposable.add(it) } } override fun onSubscribe(d: Disposable) { - disposable = d + compositeDisposable.add(d) } override fun onSuccess(draft: VoiceNoteDraft) { @@ -142,13 +155,27 @@ class VoiceMessageRecordingDelegate( Toast.makeText(fragment.requireContext(), R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show() Log.e(TAG, "Error in RecordingSession.", e) + val currentSnapshot = audioRecorder.getCurrentRecordingSnapshot() + if (currentSnapshot != null) { + sessionCallback.cancelEphemeralVoiceNoteDraft(currentSnapshot) + } + session?.dispose() session = null } - override fun dispose() = disposable.dispose() + override fun dispose() { + compositeDisposable.dispose() + } - override fun isDisposed(): Boolean = disposable.isDisposed + override fun isDisposed(): Boolean = compositeDisposable.isDisposed + + private fun savePeriodicDraftSnapshot() { + val snapshot = audioRecorder.getCurrentRecordingSnapshot() + if (snapshot != null) { + sessionCallback.saveEphemeralVoiceNoteDraft(snapshot) + } + } fun completeRecording() { shouldSend = true @@ -158,7 +185,16 @@ class VoiceMessageRecordingDelegate( fun discardRecording() { saveDraft = false shouldSend = false + + val currentSnapshot = audioRecorder.getCurrentRecordingSnapshot() audioRecorder.discardRecording() + + if (currentSnapshot != null) { + sessionCallback.cancelEphemeralVoiceNoteDraft(currentSnapshot) + } + + session?.dispose() + session = null } fun saveDraft() { 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 354a09e9d8..855a75738d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -35,11 +35,15 @@ import org.thoughtcrime.securesms.util.MediaUtil; public class AudioSlide extends Slide { public static @NonNull AudioSlide createFromVoiceNoteDraft(@NonNull DraftTable.Draft draft) { + return createFromVoiceNoteDraft(draft, false); + } + + public static @NonNull AudioSlide createFromVoiceNoteDraft(@NonNull DraftTable.Draft draft, boolean isFinal) { VoiceNoteDraft voiceNoteDraft = VoiceNoteDraft.fromDraft(draft); return new AudioSlide(new UriAttachment(voiceNoteDraft.getUri(), MediaUtil.AUDIO_AAC, - AttachmentTable.TRANSFER_PROGRESS_DONE, + isFinal ? AttachmentTable.TRANSFER_PROGRESS_DONE : AttachmentTable.TRANSFER_PROGRESS_STARTED, voiceNoteDraft.getSize(), 0, 0, 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 49b1862ecd..e82e6f4444 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -551,6 +551,19 @@ public class BlobProvider { { return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.ATTACHMENT_DRAFT)); } + + /** + * Builds the URI for a draft attachment without waiting for the data to be written. + * This is useful for getting a URI reference while data is still being written asynchronously. + * The URI can be used to check file size and save periodic snapshots. + *

+ * It is the caller's responsibility to eventually call {@link BlobProvider#delete(Context, Uri)} + * when the blob is no longer in use. + */ + @WorkerThread + public Uri buildUriForDraftAttachment() { + return buildUri(buildBlobSpec(StorageType.ATTACHMENT_DRAFT)); + } } private synchronized void waitUntilInitialized() {