Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ class MessageInputViewModel :
}

fun startMicInput(context: Context) {
audioFocusRequestManager.audioFocusRequest(true) {
audioFocusRequestManager.audioFocusRequest(true, waitForOtherAudio = true) {
audioRecorderManager.start(context)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading