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