Android acquired the microphone on the UI thread too

Porting the iOS recorder fix back the other way, and the same bug was here.

`MediaRecorder.prepare()` and `start()` acquire the microphone, and acquiring
hardware blocks. `startRecording` was a plain function called straight from the
UI, so that happened on the main thread — as did `File.createTempFile` beside
it. `cancelRecording` released the recorder the same way.

⚠️ **The tell was the inconsistency.** `stopAndSendRecording` already went
through `withContext(Dispatchers.IO)`, so someone had thought about this for
stopping and not for starting — and starting is the half that waits for the
audio route to come up. On iOS the identical mistake froze the main thread for
SIXTY SECONDS on a simulator; here it did not misbehave on the emulator, which
is not the same as being correct on a device whose audio route is busy.

All three paths now agree. The UI state on cancel is cleared immediately rather
than after the hardware is handed back, because the user pressed Cancel and
should not watch the bar linger.

Verified on the emulator: recording still starts (timer ticking at 8.4s),
cancel still stops it and the composer returns, app alive throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-26 11:32:18 -04:00
co-authored by Claude Opus 5
parent ea338ea81a
commit 6527f76151
@@ -945,19 +945,32 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
*/ */
fun startRecording() { fun startRecording() {
if (recording != null) return if (recording != null) return
val started = runCatching { VoiceNote.start(getApplication<Application>().cacheDir) } viewModelScope.launch {
started.onFailure { failure -> // 🔴 **Off the main thread.** `MediaRecorder.prepare()` and
update { it.copy(error = "Could not start recording: ${failure.message}") } // `start()` acquire the microphone, and acquiring hardware blocks —
return // on a device whose audio route is busy that is the UI thread
} // stalled for as long as it takes. `stopAndSendRecording` below
recording = started.getOrNull() // already went through Dispatchers.IO for exactly this reason;
// A visible timer, because a recorder with no feedback is one people // starting did not, and starting is the half that has to wait for
// talk into after it has already stopped. // the route to come up. The iOS recorder had the same bug and
recordingTicker = viewModelScope.launch { // froze its main thread for SIXTY SECONDS on a simulator.
while (recording != null) { val started = withContext(Dispatchers.IO) {
val seconds = recording?.elapsedSeconds runCatching { VoiceNote.start(getApplication<Application>().cacheDir) }
update { it.copy(openChat = it.openChat?.copy(recordingSeconds = seconds)) } }
kotlinx.coroutines.delay(200) val begun = started.getOrElse { failure ->
update { it.copy(error = "Could not start recording: ${failure.message}") }
return@launch
}
recording = begun
// A visible timer, because a recorder with no feedback is one
// people talk into after it has already stopped.
recordingTicker = viewModelScope.launch {
while (recording != null) {
val seconds = recording?.elapsedSeconds
update { it.copy(openChat = it.openChat?.copy(recordingSeconds = seconds)) }
kotlinx.coroutines.delay(200)
}
} }
} }
} }
@@ -966,9 +979,16 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun cancelRecording() { fun cancelRecording() {
recordingTicker?.cancel() recordingTicker?.cancel()
recordingTicker = null recordingTicker = null
recording?.cancel() // ⚠️ Releasing the recorder blocks too, so it goes the same way as
// starting and stopping. The UI state is cleared immediately — the
// user pressed Cancel and should not watch a bar linger while the
// hardware is handed back.
val discarded = recording
recording = null recording = null
update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) } update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) }
if (discarded != null) {
viewModelScope.launch { withContext(Dispatchers.IO) { discarded.cancel() } }
}
} }
/** /**