mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-20 02:58:45 +00:00
Write voice recording data in 1s intervals to drafts.
This commit is contained in:
committed by
Cody Henthorne
parent
7978cc668d
commit
0afa75564f
@@ -35,9 +35,9 @@ public class AudioRecorder {
|
|||||||
private final AudioRecordingHandler uiHandler;
|
private final AudioRecordingHandler uiHandler;
|
||||||
private final AudioRecorderFocusManager audioFocusManager;
|
private final AudioRecorderFocusManager audioFocusManager;
|
||||||
|
|
||||||
private Recorder recorder;
|
private Recorder recorder;
|
||||||
private Future<Uri> recordingUriFuture;
|
private Future<Uri> recordingUriFuture;
|
||||||
|
private volatile Uri recordingUri;
|
||||||
private SingleSubject<VoiceNoteDraft> recordingSubject;
|
private SingleSubject<VoiceNoteDraft> recordingSubject;
|
||||||
|
|
||||||
public AudioRecorder(@NonNull Context context, @Nullable AudioRecordingHandler uiHandler) {
|
public AudioRecorder(@NonNull Context context, @Nullable AudioRecordingHandler uiHandler) {
|
||||||
@@ -88,10 +88,12 @@ public class AudioRecorder {
|
|||||||
|
|
||||||
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
|
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
|
||||||
|
|
||||||
recordingUriFuture = BlobProvider.getInstance()
|
BlobProvider.BlobBuilder blobBuilder = BlobProvider.getInstance()
|
||||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||||
.withMimeType(MediaUtil.AUDIO_AAC)
|
.withMimeType(MediaUtil.AUDIO_AAC);
|
||||||
.createForDraftAttachmentAsync(context);
|
|
||||||
|
recordingUri = blobBuilder.buildUriForDraftAttachment();
|
||||||
|
recordingUriFuture = blobBuilder.createForDraftAttachmentAsync(context);
|
||||||
|
|
||||||
recorder = useMediaRecorderWrapper ? new MediaRecorderWrapper() : new AudioCodec();
|
recorder = useMediaRecorderWrapper ? new MediaRecorderWrapper() : new AudioCodec();
|
||||||
int focusResult = audioFocusManager.requestAudioFocus();
|
int focusResult = audioFocusManager.requestAudioFocus();
|
||||||
@@ -130,6 +132,7 @@ public class AudioRecorder {
|
|||||||
recordingSubject = null;
|
recordingSubject = null;
|
||||||
recorder = null;
|
recorder = null;
|
||||||
recordingUriFuture = null;
|
recordingUriFuture = null;
|
||||||
|
recordingUri = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +160,27 @@ public class AudioRecorder {
|
|||||||
recordingSubject = null;
|
recordingSubject = null;
|
||||||
recorder = null;
|
recorder = null;
|
||||||
recordingUriFuture = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ class DraftViewModel @JvmOverloads constructor(
|
|||||||
|
|
||||||
fun cancelEphemeralVoiceNoteDraft(draft: Draft) {
|
fun cancelEphemeralVoiceNoteDraft(draft: Draft) {
|
||||||
repository.deleteVoiceNoteDraftData(draft)
|
repository.deleteVoiceNoteDraftData(draft)
|
||||||
|
store.update {
|
||||||
|
saveDraftsIfChanged(it, it.copy(voiceNoteDraft = null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteVoiceNoteDraft() {
|
fun deleteVoiceNoteDraft() {
|
||||||
|
|||||||
@@ -1238,7 +1238,9 @@ class ConversationFragment :
|
|||||||
.state
|
.state
|
||||||
.distinctUntilChanged { previous, next -> previous.voiceNoteDraft == next.voiceNoteDraft }
|
.distinctUntilChanged { previous, next -> previous.voiceNoteDraft == next.voiceNoteDraft }
|
||||||
.subscribe {
|
.subscribe {
|
||||||
inputPanel.voiceNoteDraft = it.voiceNoteDraft
|
if (!voiceMessageRecordingDelegate.hasActiveSession()) {
|
||||||
|
inputPanel.voiceNoteDraft = it.voiceNoteDraft
|
||||||
|
}
|
||||||
updateToggleButtonState()
|
updateToggleButtonState()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -4377,9 +4379,6 @@ class ConversationFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onRecorderCanceled(byUser: Boolean) {
|
override fun onRecorderCanceled(byUser: Boolean) {
|
||||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
|
|
||||||
updateToggleButtonState()
|
|
||||||
}
|
|
||||||
voiceMessageRecordingDelegate.onRecorderCanceled(byUser)
|
voiceMessageRecordingDelegate.onRecorderCanceled(byUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import android.view.WindowManager
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import io.reactivex.rxjava3.core.SingleObserver
|
import io.reactivex.rxjava3.core.SingleObserver
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
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.LifecycleDisposable
|
||||||
import org.signal.core.util.concurrent.addTo
|
import org.signal.core.util.concurrent.addTo
|
||||||
import org.signal.core.util.logging.Log
|
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.components.voice.VoiceNoteDraft
|
||||||
import org.thoughtcrime.securesms.conversation.VoiceRecorderWakeLock
|
import org.thoughtcrime.securesms.conversation.VoiceRecorderWakeLock
|
||||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delegate class for VoiceMessage recording.
|
* Delegate class for VoiceMessage recording.
|
||||||
@@ -33,6 +37,7 @@ class VoiceMessageRecordingDelegate(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = Log.tag(VoiceMessageRecordingDelegate::class.java)
|
private val TAG = Log.tag(VoiceMessageRecordingDelegate::class.java)
|
||||||
|
private const val PERIODIC_DRAFT_SAVE_INTERVAL_MS = 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val disposables = LifecycleDisposable().apply {
|
private val disposables = LifecycleDisposable().apply {
|
||||||
@@ -43,6 +48,8 @@ class VoiceMessageRecordingDelegate(
|
|||||||
|
|
||||||
private var session: Session? = null
|
private var session: Session? = null
|
||||||
|
|
||||||
|
fun hasActiveSession(): Boolean = session != null
|
||||||
|
|
||||||
fun onRecorderStarted() {
|
fun onRecorderStarted() {
|
||||||
beginRecording()
|
beginRecording()
|
||||||
}
|
}
|
||||||
@@ -115,16 +122,22 @@ class VoiceMessageRecordingDelegate(
|
|||||||
|
|
||||||
private var saveDraft = true
|
private var saveDraft = true
|
||||||
private var shouldSend = false
|
private var shouldSend = false
|
||||||
private var disposable = Disposable.empty()
|
private val compositeDisposable = CompositeDisposable()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observable
|
observable
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(this)
|
.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) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
disposable = d
|
compositeDisposable.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSuccess(draft: VoiceNoteDraft) {
|
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()
|
Toast.makeText(fragment.requireContext(), R.string.ConversationActivity_unable_to_record_audio, Toast.LENGTH_LONG).show()
|
||||||
Log.e(TAG, "Error in RecordingSession.", e)
|
Log.e(TAG, "Error in RecordingSession.", e)
|
||||||
|
|
||||||
|
val currentSnapshot = audioRecorder.getCurrentRecordingSnapshot()
|
||||||
|
if (currentSnapshot != null) {
|
||||||
|
sessionCallback.cancelEphemeralVoiceNoteDraft(currentSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
session?.dispose()
|
session?.dispose()
|
||||||
session = null
|
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() {
|
fun completeRecording() {
|
||||||
shouldSend = true
|
shouldSend = true
|
||||||
@@ -158,7 +185,16 @@ class VoiceMessageRecordingDelegate(
|
|||||||
fun discardRecording() {
|
fun discardRecording() {
|
||||||
saveDraft = false
|
saveDraft = false
|
||||||
shouldSend = false
|
shouldSend = false
|
||||||
|
|
||||||
|
val currentSnapshot = audioRecorder.getCurrentRecordingSnapshot()
|
||||||
audioRecorder.discardRecording()
|
audioRecorder.discardRecording()
|
||||||
|
|
||||||
|
if (currentSnapshot != null) {
|
||||||
|
sessionCallback.cancelEphemeralVoiceNoteDraft(currentSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
session?.dispose()
|
||||||
|
session = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveDraft() {
|
fun saveDraft() {
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ import org.thoughtcrime.securesms.util.MediaUtil;
|
|||||||
public class AudioSlide extends Slide {
|
public class AudioSlide extends Slide {
|
||||||
|
|
||||||
public static @NonNull AudioSlide createFromVoiceNoteDraft(@NonNull DraftTable.Draft draft) {
|
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);
|
VoiceNoteDraft voiceNoteDraft = VoiceNoteDraft.fromDraft(draft);
|
||||||
|
|
||||||
return new AudioSlide(new UriAttachment(voiceNoteDraft.getUri(),
|
return new AudioSlide(new UriAttachment(voiceNoteDraft.getUri(),
|
||||||
MediaUtil.AUDIO_AAC,
|
MediaUtil.AUDIO_AAC,
|
||||||
AttachmentTable.TRANSFER_PROGRESS_DONE,
|
isFinal ? AttachmentTable.TRANSFER_PROGRESS_DONE : AttachmentTable.TRANSFER_PROGRESS_STARTED,
|
||||||
voiceNoteDraft.getSize(),
|
voiceNoteDraft.getSize(),
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -551,6 +551,19 @@ public class BlobProvider {
|
|||||||
{
|
{
|
||||||
return writeBlobSpecToDiskAsync(context, buildBlobSpec(StorageType.ATTACHMENT_DRAFT));
|
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() {
|
private synchronized void waitUntilInitialized() {
|
||||||
|
|||||||
Reference in New Issue
Block a user