diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0606d47..8d29580 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,7 @@ android { initWith(getByName("release")) matchingFallbacks += listOf("release") buildConfigField("boolean", "IS_OFFLINE", "true") + buildConfigField("String", "TTS_WORKER_URL", "\"\"") } } diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 8aeab8f..3f071d5 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -1891,6 +1891,7 @@ fun DeviceVoicesTab( val allLanguagesLabel = stringResource(R.string.filter_all) var selectedLanguage by remember { mutableStateOf(allLanguagesLabel) } var languageMenuExpanded by remember { mutableStateOf(false) } + val offlineNativeOnly = BuildConfig.IS_OFFLINE DisposableEffect(Unit) { val tts = TextToSpeech(context) { status -> @@ -1903,20 +1904,35 @@ fun DeviceVoicesTab( onDispose { tts.shutdown() } } + LaunchedEffect(allVoices, savedVoiceName, offlineNativeOnly) { + if ( + offlineNativeOnly && + savedVoiceName != null && + allVoices.any { voice -> voice.name == savedVoiceName && voice.isNetworkConnectionRequired } + ) { + savedVoiceName = null + saveNativeVoice(context, null) + } + } + if (isTtsLoading) { Box(modifier = Modifier.fillMaxWidth().height(150.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } return } - val languages = remember(allVoices) { - val list = listOf(allLanguagesLabel) + allVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted() + val selectableVoices = remember(allVoices, offlineNativeOnly) { + if (offlineNativeOnly) allVoices.filter { voice -> !voice.isNetworkConnectionRequired } else allVoices + } + + val languages = remember(selectableVoices) { + val list = listOf(allLanguagesLabel) + selectableVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted() Timber.tag("TTS_DIAGNOSE").d("Languages list updated: size=${list.size}, items=$list") list } - val filteredVoices = remember(allVoices, selectedLanguage) { - if (selectedLanguage == allLanguagesLabel) allVoices - else allVoices.filter { it.locale.displayLanguage == selectedLanguage } + val filteredVoices = remember(selectableVoices, selectedLanguage) { + if (selectedLanguage == allLanguagesLabel) selectableVoices + else selectableVoices.filter { it.locale.displayLanguage == selectedLanguage } } val isBaseMode = currentMode == TtsPlaybackManager.TtsMode.BASE @@ -1934,12 +1950,19 @@ fun DeviceVoicesTab( try { val defaultLocale = Locale.getDefault() language = defaultLocale - val fallbackVoice = + val fallbackVoice = if (offlineNativeOnly) { + voices.firstOrNull { voice -> + voice.locale == defaultLocale && !voice.isNetworkConnectionRequired + } ?: voices.firstOrNull { voice -> + !voice.isNetworkConnectionRequired + } + } else { defaultVoice ?: voices.firstOrNull { voice -> voice.locale == defaultLocale && !voice.isNetworkConnectionRequired } ?: voices.firstOrNull { voice -> voice.locale == defaultLocale } + } fallbackVoice?.let { voice = it } } catch (e: Exception) { Timber.tag("TTS_DIAGNOSE").w(e, "Failed to reset preview engine to system default voice") diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt index e9a1b1e..d1f0136 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt @@ -23,6 +23,8 @@ import android.content.Context import android.os.Bundle import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener +import android.speech.tts.Voice +import com.aryan.reader.BuildConfig import com.aryan.reader.epubreader.loadTtsPitch import com.aryan.reader.epubreader.loadTtsSpeechRate import com.aryan.reader.loadNativeVoice @@ -47,6 +49,38 @@ private const val START_TIMEOUT_RETRY_MS = 4000L private const val PROCESS_TIMEOUT_MS = 15000L private const val MAX_RETRY_ATTEMPTS = 3 +internal fun resolveNativeTtsVoiceForBuild( + preferredVoiceName: String?, + defaultVoice: Voice?, + availableVoices: Collection?, + defaultLocale: Locale, + isOfflineBuild: Boolean +): Voice? { + val voices = availableVoices.orEmpty() + val preferredVoice = preferredVoiceName + ?.takeIf { it.isNotBlank() } + ?.let { name -> voices.firstOrNull { it.name == name } } + + if (preferredVoice != null && (!isOfflineBuild || !preferredVoice.isNetworkConnectionRequired)) { + return preferredVoice + } + + val localeOfflineVoice = voices.firstOrNull { voice -> + voice.locale == defaultLocale && !voice.isNetworkConnectionRequired + } + val anyOfflineVoice = voices.firstOrNull { voice -> !voice.isNetworkConnectionRequired } + + return if (isOfflineBuild) { + localeOfflineVoice + ?: anyOfflineVoice + ?: defaultVoice?.takeUnless { it.isNetworkConnectionRequired } + } else { + defaultVoice + ?: localeOfflineVoice + ?: voices.firstOrNull { voice -> voice.locale == defaultLocale } + } +} + class BaseTtsSynthesizer(private val context: Context) { private var tts: TextToSpeech? = null @@ -149,51 +183,39 @@ class BaseTtsSynthesizer(private val context: Context) { try { val preferredVoiceName = loadNativeVoice(context) + val defaultLocale = Locale.getDefault() + val defaultVoice = tts?.defaultVoice + val availableVoices = tts?.voices + val targetVoice = resolveNativeTtsVoiceForBuild( + preferredVoiceName = preferredVoiceName, + defaultVoice = defaultVoice, + availableVoices = availableVoices, + defaultLocale = defaultLocale, + isOfflineBuild = BuildConfig.IS_OFFLINE + ) - if (preferredVoiceName.isNullOrBlank()) { - val defaultLocale = Locale.getDefault() - try { - tts?.language = defaultLocale - } catch (e: Exception) { - Timber.e(e, "BaseTts: Failed to restore default language") - } - - val defaultVoice = try { - tts?.defaultVoice ?: tts?.voices?.firstOrNull { voice -> - voice.locale == defaultLocale && !voice.isNetworkConnectionRequired - } ?: tts?.voices?.firstOrNull { voice -> - voice.locale == defaultLocale - } - } catch (e: Exception) { - Timber.e(e, "BaseTts: Failed to query default voice") - null - } - - if (defaultVoice != null && tts?.voice?.name != defaultVoice.name) { - Timber.d("BaseTts: Restoring system default voice to ${defaultVoice.name} (${defaultVoice.locale})") - tts?.voice = defaultVoice - } else { - Timber.d("BaseTts: Using engine default voice for locale $defaultLocale") - } + if (targetVoice == null) { + tts?.language = defaultLocale + Timber.w("BaseTts: No suitable local voice found for locale $defaultLocale.") return } - if (tts?.voice?.name == preferredVoiceName) return + if ( + !preferredVoiceName.isNullOrBlank() && + targetVoice.name != preferredVoiceName && + BuildConfig.IS_OFFLINE + ) { + Timber.w("BaseTts: Saved voice '$preferredVoiceName' requires network or is unavailable in offline build. Using ${targetVoice.name}.") + } - val availableVoices = tts?.voices - if (availableVoices != null) { - val targetVoice = availableVoices.find { it.name == preferredVoiceName } - if (targetVoice != null) { - Timber.d("BaseTts: Setting preferred voice to ${targetVoice.name} (${targetVoice.locale})") - try { - tts?.language = targetVoice.locale - } catch (e: Exception) { - Timber.e(e, "BaseTts: Failed to set language for voice") - } - tts?.voice = targetVoice - } else { - Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.") + if (tts?.voice?.name != targetVoice.name) { + Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})") + try { + tts?.language = targetVoice.locale + } catch (e: Exception) { + Timber.e(e, "BaseTts: Failed to set language for voice") } + tts?.voice = targetVoice } } catch (e: Exception) { Timber.e(e, "BaseTts: Failed to apply preferred voice") diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt index 21910ba..dcf3f2a 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -58,19 +58,66 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode { val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name) ?: TtsPlaybackManager.TtsMode.BASE.name - val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context) + return resolveTtsModeForCurrentBuild(context, savedModeName) +} - return if (isCloudAllowed) { - try { - TtsPlaybackManager.TtsMode.valueOf(savedModeName) - } catch (_: Exception) { - TtsPlaybackManager.TtsMode.BASE - } +@OptIn(UnstableApi::class) +internal fun resolveTtsModeForCurrentBuild( + context: Context, + requestedModeName: String? +): TtsPlaybackManager.TtsMode { + return resolveTtsModeForBuild( + requestedModeName = requestedModeName, + isOfflineBuild = BuildConfig.IS_OFFLINE, + isProBuild = BuildConfig.IS_PRO, + workerUrl = BuildConfig.TTS_WORKER_URL, + byokCloudAvailable = isByokCloudTtsAvailable(context) + ) +} + +@OptIn(UnstableApi::class) +internal fun resolveTtsModeForBuild( + requestedModeName: String?, + isOfflineBuild: Boolean, + isProBuild: Boolean, + workerUrl: String, + byokCloudAvailable: Boolean +): TtsPlaybackManager.TtsMode { + val requestedMode = try { + TtsPlaybackManager.TtsMode.valueOf( + requestedModeName ?: TtsPlaybackManager.TtsMode.BASE.name + ) + } catch (_: Exception) { + TtsPlaybackManager.TtsMode.BASE + } + + if (requestedMode != TtsPlaybackManager.TtsMode.CLOUD) { + return requestedMode + } + + return if (isCloudTtsAllowedForBuild( + isOfflineBuild = isOfflineBuild, + isProBuild = isProBuild, + workerUrl = workerUrl, + byokCloudAvailable = byokCloudAvailable + ) + ) { + TtsPlaybackManager.TtsMode.CLOUD } else { TtsPlaybackManager.TtsMode.BASE } } +internal fun isCloudTtsAllowedForBuild( + isOfflineBuild: Boolean, + isProBuild: Boolean, + workerUrl: String, + byokCloudAvailable: Boolean +): Boolean { + if (isOfflineBuild) return false + return (isProBuild && workerUrl.isNotBlank()) || byokCloudAvailable +} + @UnstableApi class TtsController(context: Context) : Player.Listener { @@ -168,9 +215,10 @@ class TtsController(context: Context) : Player.Listener { Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.") return } - Timber.d("UI sending START command with mode: $ttsMode") + val effectiveTtsMode = resolveTtsModeForCurrentBuild(context, ttsMode.name) + Timber.d("UI sending START command with mode: $effectiveTtsMode") Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( - "TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters" + "TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$effectiveTtsMode, requestedMode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters" ) val textList = ArrayList(chunks.map { it.text }) @@ -193,13 +241,13 @@ class TtsController(context: Context) : Player.Listener { pageIndex?.let { putInt(KEY_PAGE_INDEX, it) } putInt(KEY_START_CHUNK_INDEX, startChunkIndex) putBoolean(KEY_CONTINUE_SESSION, continueSession) - putString(KEY_TTS_MODE, ttsMode.name) + putString(KEY_TTS_MODE, effectiveTtsMode.name) putString(KEY_PLAYBACK_SOURCE, playbackSource) putString(KEY_AUTH_TOKEN, authToken) putFloat("playback_speed", loadTtsSpeechRate(context)) putFloat("playback_pitch", loadTtsPitch(context)) } - Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}") + Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $effectiveTtsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}") val controller = mediaController if (controller == null) { Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.") @@ -240,12 +288,13 @@ class TtsController(context: Context) : Player.Listener { @Suppress("unused") fun changeTtsMode(mode: String) { Timber.d("UI sending CHANGE_TTS_MODE command.") + val effectiveMode = resolveTtsModeForCurrentBuild(context, mode) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) - prefs.edit { putString("tts_mode", mode) } - _ttsState.value = _ttsState.value.copy(ttsMode = mode) + prefs.edit { putString("tts_mode", effectiveMode.name) } + _ttsState.value = _ttsState.value.copy(ttsMode = effectiveMode.name) val args = Bundle().apply { - putString(KEY_TTS_MODE, mode) + putString(KEY_TTS_MODE, effectiveMode.name) } mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args) } diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index a69a796..52231ae 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -444,7 +444,7 @@ class TtsPlaybackManager( val startChunkIndex = args.getInt(KEY_START_CHUNK_INDEX, 0) val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name) val playbackSource = args.getString(KEY_PLAYBACK_SOURCE) - val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD } + val ttsMode = resolveTtsModeForCurrentBuild(appContext, ttsModeName) val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) { chunks.mapIndexed { index, text -> @@ -483,7 +483,7 @@ class TtsPlaybackManager( } CHANGE_TTS_MODE_COMMAND -> { val newModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name) - val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD } + val newMode = resolveTtsModeForCurrentBuild(appContext, newModeName) handleChangeTtsMode(newMode) } FLUSH_PREFETCH_COMMAND -> { diff --git a/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt b/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt new file mode 100644 index 0000000..14029b6 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/tts/TtsModePolicyTest.kt @@ -0,0 +1,125 @@ +package com.aryan.reader.tts + +import android.speech.tts.Voice +import androidx.media3.common.util.UnstableApi +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Locale + +@androidx.annotation.OptIn(UnstableApi::class) +@RunWith(RobolectricTestRunner::class) +class TtsModePolicyTest { + @Test + fun `offline builds force device tts even when cloud config is present`() { + val mode = resolveTtsModeForBuild( + requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name, + isOfflineBuild = true, + isProBuild = true, + workerUrl = "https://example.com/tts", + byokCloudAvailable = true + ) + + assertEquals(TtsPlaybackManager.TtsMode.BASE, mode) + } + + @Test + fun `oss builds ignore server backed cloud tts without byok`() { + val mode = resolveTtsModeForBuild( + requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name, + isOfflineBuild = false, + isProBuild = false, + workerUrl = "https://example.com/tts", + byokCloudAvailable = false + ) + + assertEquals(TtsPlaybackManager.TtsMode.BASE, mode) + } + + @Test + fun `pro builds can use configured cloud tts`() { + val mode = resolveTtsModeForBuild( + requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name, + isOfflineBuild = false, + isProBuild = true, + workerUrl = "https://example.com/tts", + byokCloudAvailable = false + ) + + assertEquals(TtsPlaybackManager.TtsMode.CLOUD, mode) + } + + @Test + fun `oss builds can use byok cloud tts when online`() { + val mode = resolveTtsModeForBuild( + requestedModeName = TtsPlaybackManager.TtsMode.CLOUD.name, + isOfflineBuild = false, + isProBuild = false, + workerUrl = "", + byokCloudAvailable = true + ) + + assertEquals(TtsPlaybackManager.TtsMode.CLOUD, mode) + } + + @Test + fun `invalid tts mode falls back to device tts`() { + val mode = resolveTtsModeForBuild( + requestedModeName = "REMOTE", + isOfflineBuild = false, + isProBuild = true, + workerUrl = "https://example.com/tts", + byokCloudAvailable = false + ) + + assertEquals(TtsPlaybackManager.TtsMode.BASE, mode) + } + + @Test + fun `offline native tts ignores saved network voice`() { + val localVoice = voice("local", requiresNetwork = false) + val networkVoice = voice("network", requiresNetwork = true) + + val resolved = resolveNativeTtsVoiceForBuild( + preferredVoiceName = networkVoice.name, + defaultVoice = networkVoice, + availableVoices = listOf(networkVoice, localVoice), + defaultLocale = Locale.US, + isOfflineBuild = true + ) + + assertEquals(localVoice.name, resolved?.name) + } + + @Test + fun `online native tts keeps saved network voice`() { + val localVoice = voice("local", requiresNetwork = false) + val networkVoice = voice("network", requiresNetwork = true) + + val resolved = resolveNativeTtsVoiceForBuild( + preferredVoiceName = networkVoice.name, + defaultVoice = localVoice, + availableVoices = listOf(localVoice, networkVoice), + defaultLocale = Locale.US, + isOfflineBuild = false + ) + + assertEquals(networkVoice.name, resolved?.name) + } + + private fun voice( + name: String, + locale: Locale = Locale.US, + requiresNetwork: Boolean + ): Voice { + return Voice( + name, + locale, + Voice.QUALITY_NORMAL, + Voice.LATENCY_NORMAL, + requiresNetwork, + emptySet() + ) + } +}