Write voice recording data in 1s intervals to drafts.

This commit is contained in:
Alex Hart
2025-11-19 13:18:40 -04:00
committed by Cody Henthorne
parent 7978cc668d
commit 0afa75564f
6 changed files with 95 additions and 16 deletions

View File

@@ -35,9 +35,9 @@ public class AudioRecorder {
private final AudioRecordingHandler uiHandler;
private final AudioRecorderFocusManager audioFocusManager;
private Recorder recorder;
private Future<Uri> recordingUriFuture;
private Recorder recorder;
private Future<Uri> recordingUriFuture;
private volatile Uri recordingUri;
private SingleSubject<VoiceNoteDraft> 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;
}
}
}

View File

@@ -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() {

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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.
* <p>
* 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() {