From 3273fbd4289208cee17ec103f6cc8a5b04d60cda Mon Sep 17 00:00:00 2001 From: shihabal3amri <42039842+shihabal3amri@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:08:58 +0400 Subject: [PATCH] Fix app locale synchronization, missing translations and audio routing guards --- automotive/src/main/AndroidManifest.xml | 1 + .../java/com/shilapi/xcertplay/AppLocale.kt | 63 ++++++++++++++++-- .../shilapi/xcertplay/CarPlayHostActivity.kt | 8 ++- .../com/shilapi/xcertplay/DiPlayActivity.kt | 64 +++++++++---------- .../com/shilapi/xcertplay/LocalizedOptions.kt | 31 +++++++++ common/src/main/res/values-ar/strings.xml | 13 ++++ common/src/main/res/values-es/strings.xml | 13 ++++ common/src/main/res/values-ru/strings.xml | 13 ++++ common/src/main/res/values-zh-rCN/strings.xml | 13 ++++ common/src/main/res/values/strings.xml | 13 ++++ .../src/main/res/xml/locales_config.xml | 0 .../com/shilapi/xcertplay/AppLocaleTest.kt | 62 ++++++++++++++++++ .../xcertplay/media/AndroidMediaSink.kt | 38 ++++++----- .../xcertplay/media/AudioChannelMapping.kt | 3 + .../xcertplay/media/LegacyAudioFallback.kt | 18 ++++++ .../orchestration/ManualHotspotValidation.kt | 21 ++++-- .../media/LegacyAudioFallbackTest.kt | 48 ++++++++++++++ 17 files changed, 355 insertions(+), 67 deletions(-) create mode 100644 common/src/main/java/com/shilapi/xcertplay/LocalizedOptions.kt rename {mobile => common}/src/main/res/xml/locales_config.xml (100%) create mode 100644 common/src/test/java/com/shilapi/xcertplay/AppLocaleTest.kt create mode 100644 shared/src/main/java/com/shilapi/xcertplay/media/LegacyAudioFallback.kt create mode 100644 shared/src/test/java/com/shilapi/xcertplay/media/LegacyAudioFallbackTest.kt diff --git a/automotive/src/main/AndroidManifest.xml b/automotive/src/main/AndroidManifest.xml index 61f5c73..c5562ee 100644 --- a/automotive/src/main/AndroidManifest.xml +++ b/automotive/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Xcertplay"> diff --git a/common/src/main/java/com/shilapi/xcertplay/AppLocale.kt b/common/src/main/java/com/shilapi/xcertplay/AppLocale.kt index c3fc22a..897118d 100644 --- a/common/src/main/java/com/shilapi/xcertplay/AppLocale.kt +++ b/common/src/main/java/com/shilapi/xcertplay/AppLocale.kt @@ -1,12 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-only package com.shilapi.xcertplay +import android.app.Activity +import android.app.AlertDialog +import android.app.LocaleManager +import android.os.Build +import android.os.LocaleList import android.content.Context import android.content.res.Configuration import com.shilapi.xcertplay.host.R import java.util.Locale -/** Persisted app language (system / en / zh / ar / ru / es) applied through configuration contexts. */ +/** Platform app locales on Android 13+, with a persisted context override on older Android. */ object AppLocale { const val SYSTEM = "system" const val ENGLISH = "en" @@ -20,16 +25,45 @@ object AppLocale { private const val PREFS = "diplay" private const val KEY_LANGUAGE = "app_language" - fun preference(context: Context): String = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LANGUAGE, SYSTEM) ?: SYSTEM + private const val KEY_MIGRATED = "app_language_platform_migrated" + + fun preference(context: Context): String { + if (Build.VERSION.SDK_INT >= 33) { + val locales = context.getSystemService(LocaleManager::class.java).applicationLocales + return if (locales.isEmpty) SYSTEM else locales[0].language + } + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_LANGUAGE, SYSTEM)?.takeIf { it in ALL } ?: SYSTEM + } fun save(context: Context, language: String) { require(language in ALL) - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_LANGUAGE, language).apply() + if (Build.VERSION.SDK_INT >= 33) { + context.getSystemService(LocaleManager::class.java).applicationLocales = + locale(language)?.let { LocaleList(it) } ?: LocaleList.getEmptyLocaleList() + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_MIGRATED, true).remove(KEY_LANGUAGE).apply() + } else { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_LANGUAGE, language).apply() + } } - /** Wraps [context] so resource lookups resolve against the chosen language; system stays as-is. */ + /** On Android 13+, the OS is the single source of truth for the app language. */ fun wrap(context: Context): Context { + if (Build.VERSION.SDK_INT >= 33) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (!prefs.getBoolean(KEY_MIGRATED, false)) { + val manager = context.getSystemService(LocaleManager::class.java) + val previous = locale(prefs.getString(KEY_LANGUAGE, SYSTEM) ?: SYSTEM) + // Never overwrite a language already chosen through Android Settings. + if (manager.applicationLocales.isEmpty && previous != null) { + manager.applicationLocales = LocaleList(previous) + } + prefs.edit().putBoolean(KEY_MIGRATED, true).remove(KEY_LANGUAGE).apply() + } + return context + } val locale = locale(preference(context)) ?: return context val configuration = Configuration(context.resources.configuration).apply { setLocale(locale) @@ -38,6 +72,25 @@ object AppLocale { return context.createConfigurationContext(configuration) } + fun showPicker(activity: Activity) { + var selected = ALL.indexOf(preference(activity)).coerceAtLeast(0) + AlertDialog.Builder(activity) + .setTitle(R.string.language_app_language) + .setSingleChoiceItems(ALL.map { displayName(activity, it) }.toTypedArray(), selected) { _, index -> + selected = index + } + .setPositiveButton(R.string.language_apply) { _, _ -> + val next = ALL[selected] + if (next != preference(activity)) { + save(activity, next) + // LocaleManager recreates activities itself on Android 13+. + if (Build.VERSION.SDK_INT < 33) activity.recreate() + } + } + .setNegativeButton(R.string.common_cancel, null) + .show() + } + /** Names stay in their native form for every language; only "system default" is localized. */ fun displayName(context: Context, language: String): String = when (language) { SYSTEM -> context.getString(R.string.language_system_default) diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt index 00273a7..b3f4ef2 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -566,7 +566,7 @@ class CarPlayHostActivity : ComponentActivity() { override fun onResume() { super.onResume() val languagePreference = AppLocale.preference(this) - if (languagePreference != languagePreferenceAtCreate) { + if (Build.VERSION.SDK_INT < 33 && languagePreference != languagePreferenceAtCreate) { languagePreferenceAtCreate = languagePreference recreate() return @@ -1362,6 +1362,12 @@ class CarPlayHostActivity : ComponentActivity() { ).apply { topMargin = dp(12) }, ) + content.addView(Button(this).apply { + text = getString(R.string.language_app_language) + isAllCaps = false + setOnClickListener { AppLocale.showPicker(this@CarPlayHostActivity) } + }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(12) }) + val scroll = ScrollView(this).apply { isFillViewport = true addView( diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index b072534..0e3e487 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -71,12 +71,15 @@ class DiPlayActivity : ComponentActivity() { if (uri != null) exportDiagnostics(uri) } + private var languagePreferenceAtCreate = AppLocale.SYSTEM + override fun attachBaseContext(newBase: Context) { super.attachBaseContext(AppLocale.wrap(newBase)) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + languagePreferenceAtCreate = AppLocale.preference(this) com.shilapi.xcertplay.hud.BydNavigationOutputs.onAppOpened(applicationContext) WindowCompat.setDecorFitsSystemWindows(window, true) window.statusBarColor = BG; window.navigationBarColor = BG @@ -108,7 +111,12 @@ class DiPlayActivity : ComponentActivity() { override fun onSaveInstanceState(outState: Bundle) { outState.putString("page", page); outState.putBoolean("pending_car_hotspot", pendingCarHotspotSetup); super.onSaveInstanceState(outState) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig); render() } override fun onResume() { - super.onResume(); handler.removeCallbacks(tick); handler.post(tick) + super.onResume() + if (Build.VERSION.SDK_INT < 33 && AppLocale.preference(this) != languagePreferenceAtCreate) { + recreate() + return + } + handler.removeCallbacks(tick); handler.post(tick) // Back from the car settings: refresh the car hotspot reminder on the home page. if (!initialLaunch && (page == "home" || page == "settings" || page == "connection")) render() if (initialLaunch) { @@ -315,14 +323,14 @@ class DiPlayActivity : ComponentActivity() { card.addView(button(getString(R.string.automatic_map_setup_adb), false) { showClusterAccessSetup() }, matchButton(10, 56)) if (!automatic) { val themes = DiLink51ClusterLayout.Theme.entries - choice(card, getString(R.string.instrument_theme), themes.map { it.label }, themes.indexOf(DiLink51ClusterLayout.theme(this))) { + choice(card, getString(R.string.instrument_theme), themes.map { it.localizedLabel(this) }, themes.indexOf(DiLink51ClusterLayout.theme(this))) { DiLink51ClusterLayout.saveTheme(this, themes[it]) reconnectForClusterMap() } card.addView(label(getString(R.string.manual_mode_match_the_cluster_theme_here_the_map_cannot_fo), 14, MUTED)) } val contrasts = DiLink51ClusterLayout.Contrast.entries - choice(card, getString(R.string.instrument_contrast), contrasts.map { it.label }, contrasts.indexOf(DiLink51ClusterLayout.contrast(this))) { + choice(card, getString(R.string.instrument_contrast), contrasts.map { it.localizedLabel(this) }, contrasts.indexOf(DiLink51ClusterLayout.contrast(this))) { DiLink51ClusterLayout.saveContrast(this, contrasts[it]) reconnectForClusterMap() } @@ -481,7 +489,7 @@ class DiPlayActivity : ComponentActivity() { private fun storedSsid() = AirPlayPersistence.loadManualHotspotSsid(this) private fun storedPassword() = AirPlayPersistence.loadManualHotspotPassphrase(this) private fun hotspotError(ssid: String, password: String) = - com.shilapi.xcertplay.orchestration.ManualHotspotValidation.validate(ssid, password) + com.shilapi.xcertplay.orchestration.ManualHotspotValidation.error(ssid, password)?.let { getString(it.messageResource()) } private fun saveHotspotCredentials(ssid: String, password: String) { AirPlayPersistence.saveManualHotspotSsid(this, ssid) @@ -621,7 +629,7 @@ class DiPlayActivity : ComponentActivity() { private fun carPlaySizeControl(parent: LinearLayout) { val sizes = com.shilapi.xcertplay.airplay.CarPlaySize.entries val current = com.shilapi.xcertplay.airplay.CarPlaySize.fromWidthMillimeters(AirPlayPersistence.loadWidthPhysicalMm(this)) - choice(parent, getString(R.string.carplay_size), sizes.map { it.label }, sizes.indexOf(current)) { + choice(parent, getString(R.string.carplay_size), sizes.map { it.localizedLabel(this) }, sizes.indexOf(current)) { AirPlayPersistence.saveWidthPhysicalMm(this, sizes[it].widthMillimeters) } parent.addView(label(getString(R.string.changes_the_size_of_carplay_icons_and_text_applying_a_size), 14, MUTED).apply { @@ -853,23 +861,23 @@ class DiPlayActivity : ComponentActivity() { toneStop = null testToneTrack?.let { runCatching { it.stop(); it.release() } } testToneTrack = null - val pcm = assets.open("navigation_test.pcm").use { it.readBytes() } - val track = AudioTrack( - streamType, - 44100, - AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT, - pcm.size, - AudioTrack.MODE_STREAM - ) - if (track.state != AudioTrack.STATE_INITIALIZED) { - Log.w("DiPlay", "playTestTone streamType=$streamType state=${track.state}") - track.release() - toast(getString(R.string.audio_stream_unavailable, streamType, track.state)) + var candidate: AudioTrack? = null + val track = try { + val pcm = assets.open("navigation_test.pcm").use { it.readBytes() } + AudioTrack(streamType, 44100, AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, pcm.size, AudioTrack.MODE_STREAM).also { + candidate = it + check(it.state == AudioTrack.STATE_INITIALIZED) + check(it.write(pcm, 0, pcm.size) == pcm.size) + it.play() + } + } catch (error: Exception) { + val state = candidate?.state ?: AudioTrack.STATE_UNINITIALIZED + candidate?.let { runCatching { it.release() } } + Log.w("DiPlay", "playTestTone streamType=$streamType unavailable", error) + toast(getString(R.string.audio_stream_unavailable, streamType, state)) return } - track.write(pcm, 0, pcm.size) - track.play() Log.i("DiPlay", "playTestTone streamType=$streamType state=${track.state} playState=${track.playState}") testToneTrack = track val stop = Runnable { @@ -937,21 +945,7 @@ class DiPlayActivity : ComponentActivity() { card.addView(label(getString(R.string.language_hint), 14, MUTED)) val current = AppLocale.preference(this) val languageButton = button("${getString(R.string.language_app_language)} · ${AppLocale.displayName(this, current)}", false) { } - languageButton.setOnClickListener { - var selected = AppLocale.ALL.indexOf(AppLocale.preference(this)).coerceAtLeast(0) - val labels = AppLocale.ALL.map { AppLocale.displayName(this, it) }.toTypedArray() - AlertDialog.Builder(this).setTitle(getString(R.string.language_app_language)) - .setSingleChoiceItems(labels, selected) { _, index -> selected = index } - .setPositiveButton(getString(R.string.language_apply)) { _, _ -> - val next = AppLocale.ALL[selected] - if (next != AppLocale.preference(this)) { - AppLocale.save(this, next) - recreate() - } - } - .setNegativeButton(getString(R.string.common_cancel), null) - .show() - } + languageButton.setOnClickListener { AppLocale.showPicker(this) } card.addView(languageButton, matchButton(12, 60)) } } diff --git a/common/src/main/java/com/shilapi/xcertplay/LocalizedOptions.kt b/common/src/main/java/com/shilapi/xcertplay/LocalizedOptions.kt new file mode 100644 index 0000000..d406193 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/LocalizedOptions.kt @@ -0,0 +1,31 @@ +package com.shilapi.xcertplay + +import android.content.Context +import com.shilapi.xcertplay.airplay.CarPlaySize +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.orchestration.ManualHotspotValidation + +internal fun CarPlaySize.localizedLabel(context: Context): String = context.getString(when (this) { + CarPlaySize.LARGE -> R.string.option_size_large + CarPlaySize.MEDIUM -> R.string.option_size_medium + CarPlaySize.SMALL -> R.string.option_size_small +}) + +internal fun DiLink51ClusterLayout.Theme.localizedLabel(context: Context): String = context.getString(when (this) { + DiLink51ClusterLayout.Theme.SCENARIO -> R.string.option_theme_scenario + DiLink51ClusterLayout.Theme.MAP -> R.string.option_theme_map + DiLink51ClusterLayout.Theme.SIMPLE -> R.string.option_theme_simple +}) + +internal fun DiLink51ClusterLayout.Contrast.localizedLabel(context: Context): String = context.getString(when (this) { + DiLink51ClusterLayout.Contrast.DEFAULT -> R.string.option_contrast_default + DiLink51ClusterLayout.Contrast.LIGHT -> R.string.option_contrast_light + DiLink51ClusterLayout.Contrast.DARK -> R.string.option_contrast_dark +}) + +internal fun ManualHotspotValidation.Error.messageResource(): Int = when (this) { + ManualHotspotValidation.Error.EMPTY_NAME -> R.string.hotspot_error_empty_name + ManualHotspotValidation.Error.LONG_NAME -> R.string.hotspot_error_long_name + ManualHotspotValidation.Error.INVALID_CHARACTER -> R.string.hotspot_error_invalid_character + ManualHotspotValidation.Error.PASSWORD_LENGTH -> R.string.hotspot_error_password_length +} diff --git a/common/src/main/res/values-ar/strings.xml b/common/src/main/res/values-ar/strings.xml index 5915266..d0ed1d7 100644 --- a/common/src/main/res/values-ar/strings.xml +++ b/common/src/main/res/values-ar/strings.xml @@ -387,4 +387,17 @@ هاتفك. رحلتك. تم حفظ تقريرك في الموقع المحدد. DiPlay · بانتظار خريطة CarPlay + كبير + متوسط + صغير + المشهد · خريطة جانبية + الخريطة · خريطة كاملة + بسيط · خريطة جانبية + إعداد المظهر الافتراضي + خلفية فاتحة · عدادات داكنة + خلفية داكنة · عدادات فاتحة + أدخل اسم نقطة اتصال السيارة + يجب ألا يتجاوز اسم نقطة الاتصال 32 بايت + يحتوي الاسم أو كلمة المرور على محرف غير صالح + يجب أن تتكون كلمة مرور نقطة الاتصال من 8 إلى 63 محرفًا diff --git a/common/src/main/res/values-es/strings.xml b/common/src/main/res/values-es/strings.xml index a18aa56..604f0c5 100644 --- a/common/src/main/res/values-es/strings.xml +++ b/common/src/main/res/values-es/strings.xml @@ -387,4 +387,17 @@ TU TELÉFONO. TU TRAYECTO. Tu informe se guardó en la ubicación seleccionada. DiPlay · esperando el mapa de CarPlay + Grande + Mediano + Pequeño + Escenario · mapa lateral + Mapa · mapa completo + Simple · mapa lateral + Predeterminado del tema + Fondo claro · instrumentos oscuros + Fondo oscuro · instrumentos claros + Introduce el nombre del punto de acceso del coche + El nombre del punto de acceso no debe superar los 32 bytes + El nombre o la contraseña contiene un carácter no válido + La contraseña del punto de acceso debe tener entre 8 y 63 caracteres diff --git a/common/src/main/res/values-ru/strings.xml b/common/src/main/res/values-ru/strings.xml index c0bddfb..32857ca 100644 --- a/common/src/main/res/values-ru/strings.xml +++ b/common/src/main/res/values-ru/strings.xml @@ -387,4 +387,17 @@ ВАШ ТЕЛЕФОН. ВАША ПОЕЗДКА. Отчёт сохранён в выбранном месте. DiPlay · ожидание карты CarPlay + Большой + Средний + Маленький + Сценарий · карта сбоку + Карта · полная карта + Простой · карта сбоку + По умолчанию для темы + Светлый фон · тёмные приборы + Тёмный фон · светлые приборы + Введите имя точки доступа автомобиля + Имя точки доступа не должно превышать 32 байта + Имя или пароль содержит недопустимый символ + Пароль точки доступа должен содержать от 8 до 63 символов diff --git a/common/src/main/res/values-zh-rCN/strings.xml b/common/src/main/res/values-zh-rCN/strings.xml index fb47e2a..6a773d4 100644 --- a/common/src/main/res/values-zh-rCN/strings.xml +++ b/common/src/main/res/values-zh-rCN/strings.xml @@ -398,4 +398,17 @@ 你的手机,畅享旅程。 报告已保存到所选位置。 DiPlay · 等待 CarPlay 地图 + 大 + 中 + 小 + 场景 · 侧边地图 + 地图 · 全屏地图 + 简约 · 侧边地图 + 主题默认 + 浅色背景 · 深色仪表 + 深色背景 · 浅色仪表 + 请输入车载热点名称 + 热点名称不能超过32字节 + 名称或密码包含无效字符 + 热点密码长度必须为8至63个字符 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index aa61d7a..351cf3a 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -398,4 +398,17 @@ YOUR PHONE. YOUR DRIVE. Your report was saved to the selected location. DiPlay · waiting for the CarPlay map + Large + Medium + Small + Scenario · side map + Map · full map + Simple · side map + Theme default + Light background · dark instruments + Dark background · light instruments + Enter the car hotspot name + The hotspot name must be at most 32 bytes + The name or password contains an invalid character + The hotspot password must be 8–63 characters diff --git a/mobile/src/main/res/xml/locales_config.xml b/common/src/main/res/xml/locales_config.xml similarity index 100% rename from mobile/src/main/res/xml/locales_config.xml rename to common/src/main/res/xml/locales_config.xml diff --git a/common/src/test/java/com/shilapi/xcertplay/AppLocaleTest.kt b/common/src/test/java/com/shilapi/xcertplay/AppLocaleTest.kt new file mode 100644 index 0000000..b41400d --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/AppLocaleTest.kt @@ -0,0 +1,62 @@ +package com.shilapi.xcertplay + +import android.app.LocaleManager +import android.content.Context +import android.os.LocaleList +import android.view.View +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], manifest = Config.NONE) +class AppLocaleTest { + private val context get() = RuntimeEnvironment.getApplication() + private val manager get() = context.getSystemService(LocaleManager::class.java) + + @Test fun pickerAndSystemSettingsShareTheSamePreference() { + AppLocale.save(context, AppLocale.ARABIC) + assertEquals("ar", manager.applicationLocales.toLanguageTags()) + manager.applicationLocales = LocaleList.forLanguageTags("es") + assertEquals(AppLocale.SPANISH, AppLocale.preference(context)) + assertSame(context, AppLocale.wrap(context)) + AppLocale.save(context, AppLocale.SYSTEM) + assertTrue(manager.applicationLocales.isEmpty) + assertEquals(AppLocale.SYSTEM, AppLocale.preference(context)) + } + + @Test fun oldPreferenceMigratesOnceAndCannotOverrideLaterSystemChanges() { + context.getSharedPreferences("diplay", Context.MODE_PRIVATE).edit() + .putString("app_language", "ar").commit() + AppLocale.wrap(context) + assertEquals("ar", manager.applicationLocales.toLanguageTags()) + manager.applicationLocales = LocaleList.getEmptyLocaleList() + AppLocale.wrap(context) + assertEquals(AppLocale.SYSTEM, AppLocale.preference(context)) + } + + @Test fun existingSystemChoiceWinsOverLegacyPreference() { + context.getSharedPreferences("diplay", Context.MODE_PRIVATE).edit() + .putString("app_language", "ar").commit() + manager.applicationLocales = LocaleList.forLanguageTags("zh-CN") + AppLocale.wrap(context) + assertEquals(AppLocale.SIMPLIFIED_CHINESE, AppLocale.preference(context)) + assertEquals("zh-CN", manager.applicationLocales.toLanguageTags()) + } + + @Test @Config(sdk = [28, 32]) + fun olderAndroidWrapsArabicAndReturnsToSystemWithoutChangingGlobalResources() { + val original = context.resources.configuration.locales.toLanguageTags() + AppLocale.save(context, AppLocale.ARABIC) + val wrapped = AppLocale.wrap(context) + assertEquals(Locale("ar"), wrapped.resources.configuration.locales[0]) + assertEquals(View.LAYOUT_DIRECTION_RTL, wrapped.resources.configuration.layoutDirection) + assertEquals(original, context.resources.configuration.locales.toLanguageTags()) + AppLocale.save(context, AppLocale.SYSTEM) + assertSame(context, AppLocale.wrap(context)) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt index 8f18bdc..3191931 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt @@ -641,7 +641,7 @@ private class AudioRenderer( bytesPerSecond = format.sampleRate * frameBytes val built: AudioTrack var routeLabel: String - if (advancedAudioChannelMapping) { + if (!AudioChannelMapper.usesNavigationStream(format.audioType, format.payloadType, advancedAudioChannelMapping)) { val attributes = audioAttributes() routeLabel = "usage" built = AudioTrack.Builder() @@ -653,26 +653,24 @@ private class AudioRenderer( } else { val streamType = streamType() routeLabel = "streamType=$streamType" - var candidate = AudioTrack( - streamType, - format.sampleRate, - channelMask, - encoding, - plan.trackBufferBytes, - AudioTrack.MODE_STREAM, + built = LegacyAudioFallback.build( + createLegacy = { + AudioTrack(streamType, format.sampleRate, channelMask, encoding, + plan.trackBufferBytes, AudioTrack.MODE_STREAM) + }, + isInitialized = { it.state == AudioTrack.STATE_INITIALIZED }, + release = { it.release() }, + createFallback = { + routeLabel = "streamType=$streamType(fallback=usage)" + Log.w(TAG, "streamType=$streamType rejected by this ROM; falling back to usage-based track") + AudioTrack.Builder() + .setAudioAttributes(audioAttributes()) + .setAudioFormat(pcmFormat(encoding, channelMask)) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(plan.trackBufferBytes) + .build() + }, ) - if (candidate.state != AudioTrack.STATE_INITIALIZED) { - candidate.release() - candidate = AudioTrack.Builder() - .setAudioAttributes(audioAttributes()) - .setAudioFormat(pcmFormat(encoding, channelMask)) - .setTransferMode(AudioTrack.MODE_STREAM) - .setBufferSizeInBytes(plan.trackBufferBytes) - .build() - routeLabel = "streamType=$streamType(fallback=usage)" - Log.w(TAG, "streamType=$streamType rejected by this ROM; falling back to usage-based track") - } - built = candidate } track = built val capacityBytes = built.bufferSizeInFrames * frameBytes diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/AudioChannelMapping.kt b/shared/src/main/java/com/shilapi/xcertplay/media/AudioChannelMapping.kt index b552648..af13c11 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/AudioChannelMapping.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/AudioChannelMapping.kt @@ -51,6 +51,9 @@ internal object AudioChannelMapper { } } + fun usesNavigationStream(audioType: String, payloadType: Int, advanced: Boolean): Boolean = + !advanced && map(audioType, payloadType, AudioChannelMappingMode.MOBILE_COMPATIBLE).channel == AudioChannel.NAVIGATION + private fun mapMobileCompatible( audioType: String, payloadType: Int, diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/LegacyAudioFallback.kt b/shared/src/main/java/com/shilapi/xcertplay/media/LegacyAudioFallback.kt new file mode 100644 index 0000000..2c98e80 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/LegacyAudioFallback.kt @@ -0,0 +1,18 @@ +package com.shilapi.xcertplay.media + +/** A ROM can reject a legacy stream by throwing or returning an uninitialized track. */ +internal object LegacyAudioFallback { + fun build( + createLegacy: () -> T, + isInitialized: (T) -> Boolean, + release: (T) -> Unit, + createFallback: () -> T, + ): T { + val candidate = try { createLegacy() } catch (_: RuntimeException) { null } + if (candidate != null) { + if (isInitialized(candidate)) return candidate + release(candidate) + } + return createFallback() + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/ManualHotspotValidation.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/ManualHotspotValidation.kt index e319cd7..6e24f88 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/orchestration/ManualHotspotValidation.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/ManualHotspotValidation.kt @@ -6,12 +6,21 @@ object ManualHotspotValidation { fun securityFor(passphrase: String): ManualHotspotSecurity = if (passphrase.isEmpty()) ManualHotspotSecurity.OPEN else ManualHotspotSecurity.WPA2 - /** Returns a message for the user, or null when the name and password can be used. */ - fun validate(ssid: String, passphrase: String): String? = when { - ssid.isBlank() -> "Enter the car hotspot name" - ssid.encodeToByteArray().size > 32 -> "The hotspot name must be at most 32 bytes" - '\u0000' in ssid || '\u0000' in passphrase -> "The name or password contains an invalid character" - passphrase.isNotEmpty() && passphrase.length !in 8..63 -> "The hotspot password must be 8–63 characters" + enum class Error(val message: String) { + EMPTY_NAME("Enter the car hotspot name"), + LONG_NAME("The hotspot name must be at most 32 bytes"), + INVALID_CHARACTER("The name or password contains an invalid character"), + PASSWORD_LENGTH("The hotspot password must be 8–63 characters"), + } + + fun error(ssid: String, passphrase: String): Error? = when { + ssid.isBlank() -> Error.EMPTY_NAME + ssid.encodeToByteArray().size > 32 -> Error.LONG_NAME + '\u0000' in ssid || '\u0000' in passphrase -> Error.INVALID_CHARACTER + passphrase.isNotEmpty() && passphrase.length !in 8..63 -> Error.PASSWORD_LENGTH else -> null } + + /** English diagnostic text retained for non-UI callers. */ + fun validate(ssid: String, passphrase: String): String? = error(ssid, passphrase)?.message } diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/LegacyAudioFallbackTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/LegacyAudioFallbackTest.kt new file mode 100644 index 0000000..414f2df --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/media/LegacyAudioFallbackTest.kt @@ -0,0 +1,48 @@ +package com.shilapi.xcertplay.media + +import org.junit.Assert.* +import org.junit.Test + +class LegacyAudioFallbackTest { + @Test fun constructorRejectionStillCreatesUsageTrack() { + val fallback = Any() + val result = LegacyAudioFallback.build( + createLegacy = { throw IllegalArgumentException("Unsupported stream") }, + isInitialized = { _: Any -> fail("No candidate should exist"); false }, + release = { fail("No candidate should exist") }, + createFallback = { fallback }, + ) + assertSame(fallback, result) + } + + @Test fun uninitializedCandidateIsReleasedBeforeFallback() { + val events = mutableListOf() + val result = LegacyAudioFallback.build( + createLegacy = { "legacy" }, isInitialized = { false }, + release = { events += "release:$it" }, + createFallback = { events += "fallback"; "usage" }, + ) + assertEquals("usage", result) + assertEquals(listOf("release:legacy", "fallback"), events) + } + + @Test fun workingLegacyTrackDoesNotFallBackOrRelease() { + assertEquals("legacy", LegacyAudioFallback.build( + createLegacy = { "legacy" }, isInitialized = { true }, + release = { fail("Working track must remain open") }, + createFallback = { fail("No fallback needed"); "usage" }, + )) + } + + @Test fun onlyGuidanceUsesLegacyStreams() { + for (type in listOf("telephony", "speechRecognition", "media")) { + assertFalse(type, AudioChannelMapper.usesNavigationStream(type, 100, false)) + } + for (type in listOf("default", "alert", "compatibility")) { + assertTrue(type, AudioChannelMapper.usesNavigationStream(type, 100, false)) + assertFalse(type, AudioChannelMapper.usesNavigationStream(type, 100, true)) + } + assertFalse(AudioChannelMapper.usesNavigationStream("unknown", 102, false)) + assertTrue(AudioChannelMapper.usesNavigationStream("unknown", 100, false)) + } +}