From 0e6ad3076c66157880e986b4c7f8595147547d3e Mon Sep 17 00:00:00 2001 From: Karsten Knappe Date: Thu, 29 Jan 2026 12:18:27 +0100 Subject: [PATCH 1/5] Fix voice message recording not starting after permission is granted --- .../composer/DefaultVoiceMessageComposerPresenter.kt | 12 ++++++++++++ .../DefaultVoiceMessageComposerPresenterTest.kt | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 56b0e402d2..e50017642c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -69,6 +69,7 @@ class DefaultVoiceMessageComposerPresenter( } private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO) + private var pendingEvent: VoiceMessageRecorderEvent.Start? = null private val mediaSender = mediaSenderFactory.create(timelineMode) @Composable @@ -88,6 +89,15 @@ class DefaultVoiceMessageComposerPresenter( player.setMedia(recording.file.path) } + LaunchedEffect(permissionState.permissionGranted) { + if (permissionState.permissionGranted) { + pendingEvent?.let { + localCoroutineScope.startRecording() + pendingEvent = null + } + } + } + fun handleLifecycleEvent(event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_PAUSE -> { @@ -102,6 +112,7 @@ class DefaultVoiceMessageComposerPresenter( } fun handleVoiceMessageRecorderEvent(event: VoiceMessageRecorderEvent) { + pendingEvent = null when (event) { VoiceMessageRecorderEvent.Start -> { Timber.v("Voice message record button pressed") @@ -111,6 +122,7 @@ class DefaultVoiceMessageComposerPresenter( } else -> { Timber.i("Voice message permission needed") + pendingEvent = VoiceMessageRecorderEvent.Start permissionState.eventSink(PermissionsEvent.RequestPermissions) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 48348f5e9a..7c9cc42c70 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -551,9 +551,10 @@ class DefaultVoiceMessageComposerPresenterTest { // Dialog is hidden, user accepts permissions assertThat(awaitItem().showPermissionRationaleDialog).isFalse() + // Permission is granted, recording starts automatically permissionsPresenter.setPermissionGranted() - awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + skipItems(1) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) voiceRecorder.assertCalls(started = 1) From efa6aa289d474fd4aa41a62984e5ec87a4cdb3b6 Mon Sep 17 00:00:00 2001 From: Karsten Knappe Date: Thu, 29 Jan 2026 11:31:35 +0000 Subject: [PATCH 2/5] Fix voice message recording not starting after permission is granted --- .../composer/DefaultVoiceMessageComposerPresenter.kt | 12 ++++++++++++ .../DefaultVoiceMessageComposerPresenterTest.kt | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 56b0e402d2..e50017642c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -69,6 +69,7 @@ class DefaultVoiceMessageComposerPresenter( } private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO) + private var pendingEvent: VoiceMessageRecorderEvent.Start? = null private val mediaSender = mediaSenderFactory.create(timelineMode) @Composable @@ -88,6 +89,15 @@ class DefaultVoiceMessageComposerPresenter( player.setMedia(recording.file.path) } + LaunchedEffect(permissionState.permissionGranted) { + if (permissionState.permissionGranted) { + pendingEvent?.let { + localCoroutineScope.startRecording() + pendingEvent = null + } + } + } + fun handleLifecycleEvent(event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_PAUSE -> { @@ -102,6 +112,7 @@ class DefaultVoiceMessageComposerPresenter( } fun handleVoiceMessageRecorderEvent(event: VoiceMessageRecorderEvent) { + pendingEvent = null when (event) { VoiceMessageRecorderEvent.Start -> { Timber.v("Voice message record button pressed") @@ -111,6 +122,7 @@ class DefaultVoiceMessageComposerPresenter( } else -> { Timber.i("Voice message permission needed") + pendingEvent = VoiceMessageRecorderEvent.Start permissionState.eventSink(PermissionsEvent.RequestPermissions) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 48348f5e9a..7c9cc42c70 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -551,9 +551,10 @@ class DefaultVoiceMessageComposerPresenterTest { // Dialog is hidden, user accepts permissions assertThat(awaitItem().showPermissionRationaleDialog).isFalse() + // Permission is granted, recording starts automatically permissionsPresenter.setPermissionGranted() - awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + skipItems(1) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) voiceRecorder.assertCalls(started = 1) From 35e3100f86fba70e457a9c611d6768c4ecc9809b Mon Sep 17 00:00:00 2001 From: Karsten Knappe Date: Mon, 2 Feb 2026 15:17:49 +0100 Subject: [PATCH 3/5] fix: use rememberUpdatedState for permissionsState in DefaultVoiceMessageComposerPresenter.kt --- .../composer/DefaultVoiceMessageComposerPresenter.kt | 3 ++- .../DefaultVoiceMessageComposerPresenterTest.kt | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index e50017642c..8436a81ce6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.core.net.toUri import androidx.lifecycle.Lifecycle @@ -79,7 +80,7 @@ class DefaultVoiceMessageComposerPresenter( val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial) val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } } - val permissionState = permissionsPresenter.present() + val permissionState by rememberUpdatedState (permissionsPresenter.present()) var isSending by remember { mutableStateOf(false) } var showSendFailureDialog by remember { mutableStateOf(false) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 7c9cc42c70..8a7324d9e5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -522,7 +522,9 @@ class DefaultVoiceMessageComposerPresenterTest { permissionsPresenter.setPermissionGranted() awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) - val finalState = awaitItem() + advanceUntilIdle() + + val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) voiceRecorder.assertCalls(stopped = 1, started = 1) @@ -547,15 +549,16 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(it.showPermissionRationaleDialog).isTrue() it.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale) } + skipItems(1) // Dialog is hidden, user accepts permissions assertThat(awaitItem().showPermissionRationaleDialog).isFalse() // Permission is granted, recording starts automatically permissionsPresenter.setPermissionGranted() + advanceUntilIdle() - skipItems(1) - val finalState = awaitItem() + val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) voiceRecorder.assertCalls(started = 1) @@ -580,12 +583,14 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(it.showPermissionRationaleDialog).isTrue() it.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale) } + skipItems(1) // Dialog is hidden, user tries to record again awaitItem().also { assertThat(it.showPermissionRationaleDialog).isFalse() it.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) } + skipItems(1) // Dialog is shown once again val finalState = awaitItem().also { @@ -594,6 +599,7 @@ class DefaultVoiceMessageComposerPresenterTest { } voiceRecorder.assertCalls(started = 0) + cancelAndIgnoreRemainingEvents() testPauseAndDestroy(finalState) } } From 7c6341432f485703b9075e38f5ce630aef5ffd5e Mon Sep 17 00:00:00 2001 From: Karsten Knappe Date: Tue, 3 Feb 2026 11:22:56 +0100 Subject: [PATCH 4/5] style: removes unwanted blank line in DefaultVoiceMessageComposerPresenter --- .../composer/DefaultVoiceMessageComposerPresenter.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 8436a81ce6..04a960a9fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -79,7 +79,6 @@ class DefaultVoiceMessageComposerPresenter( val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle) val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial) val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } } - val permissionState by rememberUpdatedState (permissionsPresenter.present()) var isSending by remember { mutableStateOf(false) } var showSendFailureDialog by remember { mutableStateOf(false) } From c082141b4a1643461244a042b9865b71ac4f4e05 Mon Sep 17 00:00:00 2001 From: Karsten Knappe Date: Wed, 4 Feb 2026 09:00:56 +0100 Subject: [PATCH 5/5] style: removes unwanted whitespace in DefaultVoiceMessageComposerPresenter --- .../composer/DefaultVoiceMessageComposerPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 04a960a9fd..9b5961c364 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -79,7 +79,7 @@ class DefaultVoiceMessageComposerPresenter( val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle) val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial) val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } } - val permissionState by rememberUpdatedState (permissionsPresenter.present()) + val permissionState by rememberUpdatedState(permissionsPresenter.present()) var isSending by remember { mutableStateOf(false) } var showSendFailureDialog by remember { mutableStateOf(false) }