Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7e5e8235 authored by mitulsheth's avatar mitulsheth
Browse files

fix(audio): allow other apps to resume playback after navigation TTS

- Fix audio focus handling for navigation announcements
- Ensure proper release of audio focus after TTS
parent c762188c
Loading
Loading
Loading
Loading
Loading
+0 −7
Original line number Diff line number Diff line
@@ -289,7 +289,6 @@ class MainActivity : ComponentActivity() {

    override fun onStart() {
        super.onStart()
        ferrostarWrapperRepository.androidTtsObserver.start()

        // Observe permission requests from services
        lifecycleScope.launch {
@@ -316,14 +315,8 @@ class MainActivity : ComponentActivity() {
        bindService(serviceIntent, connection, BIND_AUTO_CREATE)
    }

    override fun onStop() {
        super.onStop()
        ferrostarWrapperRepository.androidTtsObserver.stopAndClearQueue()
    }

    override fun onDestroy() {
        super.onDestroy()
        ferrostarWrapperRepository.androidTtsObserver.shutdown()
        if (bound) {
            unbindService(connection)
            bound = false
+64 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2026 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.data.audio

import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import dagger.hilt.android.qualifiers.ApplicationContext

class MapsAudioFocusController(
    @param:ApplicationContext private val context: Context
) {

    private val audioManager =
        context.getSystemService(Context.AUDIO_SERVICE) as AudioManager

    private val focusRequest: AudioFocusRequest by lazy {
        val attributes = AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
            .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
            .build()

        AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
            .setAudioAttributes(attributes)
            .setWillPauseWhenDucked(false)
            .build()
    }

    private var hasFocus = false

    fun requestFocus(): Boolean {
        if (hasFocus) return true // already holding focus

        val granted = audioManager.requestAudioFocus(focusRequest) ==
                AudioManager.AUDIOFOCUS_REQUEST_GRANTED

        hasFocus = granted
        return granted
    }

    fun abandonFocus() {
        if (!hasFocus) return

        audioManager.abandonAudioFocusRequest(focusRequest)
        hasFocus = false
    }
}
 No newline at end of file
+33 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2026 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.data.tts

import android.content.Context
import android.speech.tts.TextToSpeech
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

class AndroidTtsFactory @Inject constructor(
    @param:ApplicationContext private val context: Context
) : TtsFactory {

    override fun create(onInitListener: TextToSpeech.OnInitListener, engine: String?): TextToSpeech {
        return TextToSpeech(context, onInitListener, engine)
    }
}
 No newline at end of file
+133 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2026 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.data.tts

import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import com.stadiamaps.ferrostar.core.AndroidTtsStatusListener
import com.stadiamaps.ferrostar.core.SpokenInstructionObserver
import earth.maps.cardinal.data.audio.MapsAudioFocusController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import uniffi.ferrostar.SpokenInstruction

class MapsTtsObserver(
    private val ttsFactory: TtsFactory,
    private val audioFocusController: MapsAudioFocusController,
    private val engine: String? = null,
    private val statusObserver: AndroidTtsStatusListener? = null
) : SpokenInstructionObserver, TextToSpeech.OnInitListener {

    private val pendingInstructions = ArrayDeque<SpokenInstruction>()
    private val _muteState = MutableStateFlow(false)
    override val muteState: StateFlow<Boolean> = _muteState.asStateFlow()
    private var tts: TextToSpeech? = null
    private var initStatus: Int? = null
    val isInitializedSuccessfully: Boolean
        get() = initStatus == TextToSpeech.SUCCESS

    fun start() {
        if (tts != null) return
        tts = ttsFactory.create(this, engine)
    }

    override fun onInit(status: Int) {
        initStatus = status

        if (status != TextToSpeech.SUCCESS) {
            statusObserver?.onTtsInitialized(null, status)
            shutdown()
            return
        }

        tts?.setOnUtteranceProgressListener(progressListener)
        statusObserver?.onTtsInitialized(tts, status)
        flushPendingInstructions()
    }

    override fun setMuted(isMuted: Boolean) {
        _muteState.value = isMuted
        if (isMuted) stopAndClearQueue()
    }

    override fun onSpokenInstructionTrigger(spokenInstruction: SpokenInstruction) {
        val tts = tts ?: return
        if (!isInitializedSuccessfully) {
            if (pendingInstructions.isNotEmpty()) {
                pendingInstructions.clear()
            }
            pendingInstructions.add(spokenInstruction)
            return
        }

        if (isMuted) return

        if (!audioFocusController.requestFocus()) return

        val utteranceId = spokenInstruction.utteranceId.toString()

        val result = tts.speak(
            spokenInstruction.text,
            TextToSpeech.QUEUE_FLUSH,
            null,
            utteranceId
        )

        if (result != TextToSpeech.SUCCESS) {
            audioFocusController.abandonFocus()
            statusObserver?.onTtsSpeakError(utteranceId, result)
        }
    }

    private fun flushPendingInstructions() {
        while (pendingInstructions.isNotEmpty()) {
            val instruction = pendingInstructions.removeFirst()
            onSpokenInstructionTrigger(instruction)
        }
    }

    override fun stopAndClearQueue() {
        tts?.stop()
        audioFocusController.abandonFocus()
    }

    fun shutdown() {
        stopAndClearQueue()
        tts?.shutdown()
        tts = null
        statusObserver?.onTtsShutdownAndRelease()
    }

    private val progressListener = object : UtteranceProgressListener() {

        override fun onStart(utteranceId: String?) {
            // No-op
        }

        override fun onDone(utteranceId: String?) {
            audioFocusController.abandonFocus()
        }

        @Deprecated("Deprecated in Java")
        override fun onError(utteranceId: String?) {
            audioFocusController.abandonFocus()
        }
    }
}
 No newline at end of file
+25 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2026 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.data.tts

import android.speech.tts.TextToSpeech

interface TtsFactory {
    fun create(onInitListener: TextToSpeech.OnInitListener, engine: String? = null): TextToSpeech
}
 No newline at end of file
Loading