Skip to content

Commit cbab8a0

Browse files
Fix StrictMode violations in AudioRecordingController (#6036)
* Fix StrictMode violations in AudioRecordingController. * Update logs. * Add AudioRecordingController tests. * Suppress warning. --------- Co-authored-by: André Mion <andremion@gmail.com>
1 parent 65f3083 commit cbab8a0

File tree

5 files changed

+741
-41
lines changed

5 files changed

+741
-41
lines changed

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,7 @@ public class MessageComposerViewModel(
279279

280280
public fun seekRecordingTo(progress: Float): Unit = messageComposerController.seekRecordingTo(progress)
281281

282-
public fun sendRecording() {
283-
completeRecording()
284-
sendMessage(buildNewMessage(input.value, selectedAttachments.value))
285-
}
282+
public fun sendRecording(): Unit = messageComposerController.sendRecording()
286283

287284
/**
288285
* Disposes the inner [MessageComposerController].

stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,18 @@ import io.getstream.chat.android.client.audio.ProgressData
2222
import io.getstream.chat.android.client.audio.audioHash
2323
import io.getstream.chat.android.client.extensions.EXTRA_WAVEFORM_DATA
2424
import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider
25+
import io.getstream.chat.android.models.Attachment
2526
import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState
2627
import io.getstream.chat.android.ui.common.state.messages.composer.copy
2728
import io.getstream.log.StreamLog
2829
import io.getstream.log.TaggedLogger
30+
import io.getstream.result.Error
31+
import io.getstream.result.Result
2932
import io.getstream.sdk.chat.audio.recording.StreamMediaRecorder
3033
import kotlinx.coroutines.CoroutineScope
3134
import kotlinx.coroutines.flow.MutableStateFlow
3235
import kotlinx.coroutines.launch
36+
import kotlinx.coroutines.withContext
3337
import java.io.File
3438
import java.util.Date
3539
import kotlin.math.abs
@@ -41,15 +45,13 @@ import kotlin.math.sqrt
4145
/**
4246
* Controller responsible for recording audio messages.
4347
*
44-
* @param channelId The ID of the channel we're chatting in.
4548
* @param audioPlayer The audio player used to play audio messages.
4649
* @param mediaRecorder The media recorder used to record audio messages.
4750
* @param fileToUri Coverts [File] into Uri like string.
4851
* @param scope Coverts [File] into Uri like string.
4952
* @param scope MessageComposerController's scope.
5053
*/
5154
internal class AudioRecordingController(
52-
private val channelId: String,
5355
private val audioPlayer: AudioPlayer,
5456
private val mediaRecorder: StreamMediaRecorder,
5557
private val fileToUri: (File) -> String,
@@ -158,19 +160,22 @@ internal class AudioRecordingController(
158160
}
159161
}
160162

161-
public fun startRecording(offset: Pair<Float, Float>? = null) {
163+
suspend fun startRecording(offset: Pair<Float, Float>? = null) {
162164
val state = this.recordingState.value
163165
if (state !is RecordingState.Idle) {
164166
logger.w { "[startRecording] rejected (state is not Idle): $state" }
165167
return
166168
}
167169
logger.i { "[startRecording] state: $state" }
168170
val recordingName = "audio_recording_${Date()}.aac"
169-
mediaRecorder.startAudioRecording(recordingName, realPollingInterval.toLong())
171+
// Call on Dispatchers.IO because it accesses file system
172+
withContext(DispatcherProvider.IO) {
173+
mediaRecorder.startAudioRecording(recordingName, realPollingInterval.toLong())
174+
}
170175
setState(RecordingState.Hold(offset = offset ?: RecordingState.Hold.ZeroOffset))
171176
}
172177

173-
public fun holdRecording(offset: Pair<Float, Float>? = null) {
178+
fun holdRecording(offset: Pair<Float, Float>? = null) {
174179
val state = this.recordingState.value
175180
if (state !is RecordingState.Hold) {
176181
logger.w { "[holdRecording] rejected (state is not Hold): $state" }
@@ -184,7 +189,7 @@ internal class AudioRecordingController(
184189
setState(state.copy(offset = offset))
185190
}
186191

187-
public fun lockRecording() {
192+
fun lockRecording() {
188193
val state = this.recordingState.value
189194
if (state !is RecordingState.Hold) {
190195
logger.w { "[lockRecording] rejected (state is not Hold): $state" }
@@ -194,10 +199,10 @@ internal class AudioRecordingController(
194199
setState(RecordingState.Locked(state.durationInMs, state.waveform))
195200
}
196201

197-
public fun cancelRecording() {
202+
fun cancelRecording() {
198203
val state = this.recordingState.value
199204
if (state is RecordingState.Idle) {
200-
logger.w { "[cancelRecording] rejected (state is not Idle)" }
205+
logger.w { "[cancelRecording] rejected (state is Idle)" }
201206
return
202207
}
203208
logger.i { "[cancelRecording] state: $state" }
@@ -209,7 +214,7 @@ internal class AudioRecordingController(
209214
setState(RecordingState.Idle)
210215
}
211216

212-
public fun toggleRecordingPlayback() {
217+
fun toggleRecordingPlayback() {
213218
val state = this.recordingState.value
214219
if (state !is RecordingState.Overview) {
215220
logger.v { "[toggleRecordingPlayback] rejected (state is not Locked): $state" }
@@ -278,14 +283,17 @@ internal class AudioRecordingController(
278283
}
279284
}
280285

281-
public fun stopRecording() {
286+
suspend fun stopRecording() {
282287
val state = this.recordingState.value
283288
if (state !is RecordingState.Locked) {
284289
logger.w { "[stopRecording] rejected (state is not Locked): $state" }
285290
return
286291
}
287292
logger.i { "[stopRecording] no args: $state" }
288-
val result = mediaRecorder.stopRecording()
293+
// Call on Dispatchers.IO because it accesses file system
294+
val result = withContext(DispatcherProvider.IO) {
295+
mediaRecorder.stopRecording()
296+
}
289297
if (result.isFailure) {
290298
logger.e { "[stopRecording] failed: ${result.errorOrNull()}" }
291299
clearData()
@@ -300,7 +308,7 @@ internal class AudioRecordingController(
300308
setState(RecordingState.Overview(recorded.durationInMs, normalized, recorded.attachment))
301309
}
302310

303-
public fun seekRecordingTo(progress: Float) {
311+
fun seekRecordingTo(progress: Float) {
304312
val state = this.recordingState.value
305313
if (state !is RecordingState.Overview) {
306314
logger.w { "[seekRecordingTo] rejected (state is not Overview)" }
@@ -317,7 +325,7 @@ internal class AudioRecordingController(
317325
setState(state.copy(playingProgress = progress))
318326
}
319327

320-
public fun pauseRecording() {
328+
fun pauseRecording() {
321329
val state = this.recordingState.value
322330
if (state !is RecordingState.Overview) {
323331
logger.w { "[pauseRecording] rejected (state is not Overview)" }
@@ -328,7 +336,52 @@ internal class AudioRecordingController(
328336
setState(state.copy(isPlaying = false))
329337
}
330338

331-
public fun completeRecording() {
339+
suspend fun completeRecordingSync(): Result<Attachment> {
340+
val state = this.recordingState.value
341+
logger.w { "[completeRecordingSync] state: $state" }
342+
if (state is RecordingState.Idle) {
343+
logger.w { "[completeRecordingSync] rejected (state is Idle)" }
344+
return Result.Failure(Error.GenericError("Recording is in Idle state"))
345+
}
346+
if (state is RecordingState.Overview) {
347+
logger.d { "[completeRecordingSync] completing from Overview state" }
348+
clearData()
349+
val attachment = state.attachment.copy(
350+
extraData = state.attachment.extraData + mapOf(
351+
EXTRA_WAVEFORM_DATA to state.waveform,
352+
),
353+
)
354+
setState(RecordingState.Idle)
355+
return Result.Success(attachment)
356+
}
357+
// Call on Dispatchers.IO because it accesses file system
358+
val result = withContext(DispatcherProvider.IO) {
359+
mediaRecorder.stopRecording()
360+
}
361+
when (result) {
362+
is Result.Failure -> {
363+
logger.e { "[completeRecordingSync] failed: ${result.value}" }
364+
clearData()
365+
setState(RecordingState.Idle)
366+
return result
367+
}
368+
369+
is Result.Success -> {
370+
logger.d { "[completeRecordingSync] complete from state: $state" }
371+
val adjusted = samples.downsampleMax(samplesTarget)
372+
val normalized = adjusted.normalize()
373+
clearData()
374+
val attachment = result.value.attachment
375+
val attachmentWithWaveform = attachment.copy(
376+
extraData = attachment.extraData + mapOf(EXTRA_WAVEFORM_DATA to normalized),
377+
)
378+
setState(RecordingState.Idle)
379+
return Result.Success(attachmentWithWaveform)
380+
}
381+
}
382+
}
383+
384+
suspend fun completeRecording() {
332385
val state = this.recordingState.value
333386
logger.w { "[completeRecording] state: $state" }
334387
if (state is RecordingState.Idle) {
@@ -339,19 +392,22 @@ internal class AudioRecordingController(
339392
logger.d { "[completeRecording] completing from Overview state" }
340393
audioPlayer.resetAudio(state.playingId)
341394
clearData()
342-
setState(
343-
RecordingState.Complete(
344-
state.attachment.copy(
345-
extraData = state.attachment.extraData + mapOf(
346-
EXTRA_WAVEFORM_DATA to state.waveform,
347-
),
348-
),
395+
val complete = RecordingState.Complete(
396+
state.attachment.copy(
397+
extraData = state.attachment.extraData + mapOf(EXTRA_WAVEFORM_DATA to state.waveform),
349398
),
350399
)
351-
setState(RecordingState.Idle)
400+
// Run on Dispatchers.Main to ensure both state updates are published in order (complete and idle)
401+
withContext(DispatcherProvider.Main) {
402+
setState(complete)
403+
setState(RecordingState.Idle)
404+
}
352405
return
353406
}
354-
val result = mediaRecorder.stopRecording()
407+
// Run on Dispatchers.IO because it accesses file system
408+
val result = withContext(DispatcherProvider.IO) {
409+
mediaRecorder.stopRecording()
410+
}
355411
if (result.isFailure) {
356412
logger.e { "[completeRecording] failed: ${result.errorOrNull()}" }
357413
clearData()
@@ -364,18 +420,19 @@ internal class AudioRecordingController(
364420
val recorded = result.getOrThrow().let {
365421
it.copy(
366422
attachment = it.attachment.copy(
367-
extraData = it.attachment.extraData + mapOf(
368-
EXTRA_WAVEFORM_DATA to normalized,
369-
),
423+
extraData = it.attachment.extraData + mapOf(EXTRA_WAVEFORM_DATA to normalized),
370424
),
371425
)
372426
}
373427
logger.d { "[completeRecording] complete from state: $state" }
374-
setState(RecordingState.Complete(recorded.attachment))
375-
setState(RecordingState.Idle)
428+
// Run on Dispatchers.Main to ensure both state updates are published in order (complete and idle)
429+
withContext(DispatcherProvider.Main) {
430+
setState(RecordingState.Complete(recorded.attachment))
431+
setState(RecordingState.Idle)
432+
}
376433
}
377434

378-
public fun onCleared() {
435+
fun onCleared() {
379436
logger.i { "[onCleared] no args" }
380437
mediaRecorder.release()
381438
val state = this.recordingState.value

stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,6 @@ public class MessageComposerController(
143143
private val scope = CoroutineScope(DispatcherProvider.Immediate)
144144

145145
private val audioRecordingController = AudioRecordingController(
146-
channelCid,
147146
chatClient.audioPlayer,
148147
mediaRecorder,
149148
fileToUri,
@@ -886,7 +885,9 @@ public class MessageComposerController(
886885
* from [RecordingState.Idle] to [RecordingState.Hold].
887886
*/
888887
public fun startRecording(offset: Pair<Float, Float>? = null) {
889-
audioRecordingController.startRecording(offset)
888+
scope.launch {
889+
audioRecordingController.startRecording(offset)
890+
}
890891
}
891892

892893
/**
@@ -914,13 +915,21 @@ public class MessageComposerController(
914915
/**
915916
* Stops audio recording and moves [MessageComposerState.recording] state to [RecordingState.Overview].
916917
*/
917-
public fun stopRecording(): Unit = audioRecordingController.stopRecording()
918+
public fun stopRecording() {
919+
scope.launch {
920+
audioRecordingController.stopRecording()
921+
}
922+
}
918923

919924
/**
920925
* Completes audio recording and moves [MessageComposerState.recording] state to [RecordingState.Complete].
921926
* Also, it wil update [MessageComposerState.attachments] list.
922927
*/
923-
public fun completeRecording(): Unit = audioRecordingController.completeRecording()
928+
public fun completeRecording() {
929+
scope.launch {
930+
audioRecordingController.completeRecording()
931+
}
932+
}
924933

925934
/**
926935
* Pauses audio recording and sets [RecordingState.Overview.isPlaying] to false.
@@ -934,6 +943,18 @@ public class MessageComposerController(
934943
*/
935944
public fun seekRecordingTo(progress: Float): Unit = audioRecordingController.seekRecordingTo(progress)
936945

946+
/**
947+
* Completes the active audio recording and sends the recorded audio as an attachment.
948+
*/
949+
public fun sendRecording() {
950+
scope.launch {
951+
audioRecordingController.completeRecordingSync().onSuccess { recording ->
952+
val attachments = selectedAttachments.value + recording
953+
sendMessage(buildNewMessage(messageInput.value.text, attachments), callback = {})
954+
}
955+
}
956+
}
957+
937958
/**
938959
* Shows the mention suggestion list popup if necessary.
939960
*/

0 commit comments

Comments
 (0)