From 195a1b74d0daca3583228b72ac62c9dd6c75ae8f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 18 Oct 2026 15:39:11 +0000 Subject: [PATCH] fix(chat): wait for the music of another app to stop before a voice recording starts The recording asked for the audio focus and opened the microphone in the same call. The player of another app pauses some time after it lost the focus, so the first moments of a voice message contained the end of the music from the speaker. After the focus is granted, the start of the recording now waits until AudioManager.isMusicActive is false, for at most one second; without music there is no delay. Giving the focus back drops a start that still waits, and a recording that was released before it started is not sent. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../chat/data/io/AudioFocusRequestManager.kt | 19 ++- .../talk/chat/data/io/MusicSilenceGate.kt | 56 +++++++++ .../talk/chat/viewmodels/ChatViewModel.kt | 6 +- .../chat/viewmodels/MessageInputViewModel.kt | 2 +- .../data/io/MusicSilenceBeforeCaptureTest.kt | 116 ++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/data/io/MusicSilenceBeforeCaptureTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt index 9e2f7e71dda..2c7af0c10e2 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt @@ -64,10 +64,19 @@ class AudioFocusRequestManager(private val context: Context) { .setOnAudioFocusChangeListener(audioFocusChangeListener) .build() + private val musicSilenceGate = MusicSilenceGate(audioManager::isMusicActive) + /** - * Requests the OS for audio focus, before executing the callback on success + * Requests the OS for audio focus, before executing the callback on success. + * + * Another app gives its music up some time after it lost the focus, so a recording that is started right after + * the grant still picks the end of the music up from the speaker. With [waitForOtherAudio] the callback waits + * until the music is silent (see [MusicSilenceGate]). Abandoning the focus cancels a callback that still waits. */ - fun audioFocusRequest(shouldRequestFocus: Boolean, onGranted: () -> Unit) { + fun audioFocusRequest(shouldRequestFocus: Boolean, waitForOtherAudio: Boolean = false, onGranted: () -> Unit) { + if (!shouldRequestFocus) { + musicSilenceGate.cancel() + } if (isPausedDueToBecomingNoisy) { onGranted() return @@ -81,7 +90,11 @@ class AudioFocusRequestManager(private val context: Context) { } if (isGranted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - onGranted() + if (shouldRequestFocus && waitForOtherAudio) { + musicSilenceGate.whenSilent(onGranted) + } else { + onGranted() + } handleBecomingNoisyBroadcast(shouldRequestFocus) } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt new file mode 100644 index 00000000000..b9db99ab778 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.io + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock + +private const val MUSIC_SILENCE_POLL_MS = 50L +private const val MUSIC_SILENCE_MAX_WAIT_MS = 1000L + +/** + * Whether the start of a capture still has to wait for the music of another app to stop. The wait is limited: a + * player that does not react to the lost audio focus must not keep the recording from starting. + */ +fun shouldWaitForMusicToStop(isMusicActive: Boolean, waitedMs: Long, maxWaitMs: Long = MUSIC_SILENCE_MAX_WAIT_MS) = + isMusicActive && waitedMs < maxWaitMs + +/** + * Delays an action until no music is playing any more. A player pauses some time after it lost the audio focus, and + * a microphone that is opened in between records that music from the speaker. Without music the action runs at once. + */ +class MusicSilenceGate( + private val isMusicActive: () -> Boolean, + private val handler: Handler = Handler(Looper.getMainLooper()), + private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime +) { + private var pending: Runnable? = null + + fun whenSilent(onSilent: () -> Unit) { + cancel() + val startedAt = elapsedRealtime() + object : Runnable { + override fun run() { + pending = null + if (shouldWaitForMusicToStop(isMusicActive(), elapsedRealtime() - startedAt)) { + pending = this + handler.postDelayed(this, MUSIC_SILENCE_POLL_MS) + } else { + onSilent() + } + } + }.run() + } + + /** Drops the action that still waits. */ + fun cancel() { + pending?.let { handler.removeCallbacks(it) } + pending = null + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 140f2f8b0ea..4030586db0e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -2380,7 +2380,7 @@ class ChatViewModel @AssistedInject constructor( private fun reactionLockFor(messageId: Int): Mutex = reactionLocks.getOrPut(messageId) { Mutex() } fun startAudioRecording(context: Context, currentConversation: ConversationModel) { - audioFocusRequestManager.audioFocusRequest(true) { + audioFocusRequestManager.audioFocusRequest(true, waitForOtherAudio = true) { Log.d(TAG, "Recording Started") mediaRecorderManager.start(context, currentConversation) _getVoiceRecordingInProgress.postValue(true) @@ -2396,9 +2396,11 @@ class ChatViewModel @AssistedInject constructor( } fun stopAndSendAudioRecording(roomToken: String = "", replyToMessageId: Int? = null, displayName: String) { + // a recording that was released before the wait for the music of another app was over never started + val wasRecording = mediaRecorderManager.mediaRecorderState == MediaRecorderManager.MediaRecorderState.RECORDING stopAudioRecording() - if (mediaRecorderManager.mediaRecorderState != MediaRecorderManager.MediaRecorderState.ERROR) { + if (wasRecording && mediaRecorderManager.mediaRecorderState != MediaRecorderManager.MediaRecorderState.ERROR) { val uri = Uri.fromFile(File(mediaRecorderManager.currentVoiceRecordFile)) Log.d(TAG, "File uploaded") uploadFile( diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt index 55c9a2f8320..8b725bc5225 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt @@ -341,7 +341,7 @@ class MessageInputViewModel : } fun startMicInput(context: Context) { - audioFocusRequestManager.audioFocusRequest(true) { + audioFocusRequestManager.audioFocusRequest(true, waitForOtherAudio = true) { audioRecorderManager.start(context) } } diff --git a/app/src/test/java/com/nextcloud/talk/chat/data/io/MusicSilenceBeforeCaptureTest.kt b/app/src/test/java/com/nextcloud/talk/chat/data/io/MusicSilenceBeforeCaptureTest.kt new file mode 100644 index 00000000000..f5a33bb1cb8 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/data/io/MusicSilenceBeforeCaptureTest.kt @@ -0,0 +1,116 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat.data.io + +import android.content.Context +import android.media.AudioManager +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowAudioManager +import java.time.Duration + +/** + * The music of another app must be paused before the microphone is opened (audio focus first, capture after). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class MusicSilenceBeforeCaptureTest { + private lateinit var context: Context + private lateinit var audioManager: AudioManager + private lateinit var shadow: ShadowAudioManager + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + shadow = shadowOf(audioManager) + } + + private fun idle(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis)) + + @Test + fun `capture starts at once when no music plays`() { + var started = false + + AudioFocusRequestManager(context).audioFocusRequest(true, waitForOtherAudio = true) { started = true } + + assertTrue(started) + assertEquals( + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + shadow.lastAudioFocusRequest.audioFocusRequest.focusGain + ) + } + + @Test + fun `capture waits for the music to stop after the focus was granted`() { + shadow.setIsMusicActive(true) + var started = false + + AudioFocusRequestManager(context).audioFocusRequest(true, waitForOtherAudio = true) { started = true } + assertNotNull(shadow.lastAudioFocusRequest) + idle(200) + assertFalse("the microphone must not open while the music plays", started) + + shadow.setIsMusicActive(false) + idle(100) + assertTrue(started) + } + + @Test + fun `capture starts after the limit when the music does not stop`() { + shadow.setIsMusicActive(true) + var started = false + + AudioFocusRequestManager(context).audioFocusRequest(true, waitForOtherAudio = true) { started = true } + idle(500) + assertFalse(started) + idle(700) + + assertTrue(started) + } + + @Test + fun `giving the focus back drops a capture that still waits`() { + shadow.setIsMusicActive(true) + var started = false + val manager = AudioFocusRequestManager(context) + + manager.audioFocusRequest(true, waitForOtherAudio = true) { started = true } + manager.audioFocusRequest(false) { } + shadow.setIsMusicActive(false) + idle(2000) + + assertFalse(started) + } + + @Test + fun `request without waiting runs at once also while music plays`() { + shadow.setIsMusicActive(true) + var started = false + + AudioFocusRequestManager(context).audioFocusRequest(true) { started = true } + + assertTrue(started) + } + + @Test + fun `music wait is bounded`() { + assertTrue(shouldWaitForMusicToStop(isMusicActive = true, waitedMs = 0)) + assertFalse(shouldWaitForMusicToStop(isMusicActive = false, waitedMs = 0)) + assertFalse(shouldWaitForMusicToStop(isMusicActive = true, waitedMs = 1000)) + } +}