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)) + } +}