Fix app locale synchronization, missing translations and audio routing guards

This commit is contained in:
shihabal3amri committed 2026-09-29 11:08:58 +04:00
1 parent c9aaf09810
commit 3273fbd428
17 files changed
+355 -67

No files matched your search

+1
View File
@@ -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">
@@ -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)
@@ -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(
@@ -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))
}
}
@@ -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
}
+13
View File
@@ -387,4 +387,17 @@
<string name="your_phone_your_drive">هاتفك. رحلتك.</string>
<string name="your_report_was_saved_to_the_selected_location">تم حفظ تقريرك في الموقع المحدد.</string>
<string name="cluster_waiting_for_map">DiPlay · بانتظار خريطة CarPlay</string>
<string name="option_size_large">كبير</string>
<string name="option_size_medium">متوسط</string>
<string name="option_size_small">صغير</string>
<string name="option_theme_scenario">المشهد · خريطة جانبية</string>
<string name="option_theme_map">الخريطة · خريطة كاملة</string>
<string name="option_theme_simple">بسيط · خريطة جانبية</string>
<string name="option_contrast_default">إعداد المظهر الافتراضي</string>
<string name="option_contrast_light">خلفية فاتحة · عدادات داكنة</string>
<string name="option_contrast_dark">خلفية داكنة · عدادات فاتحة</string>
<string name="hotspot_error_empty_name">أدخل اسم نقطة اتصال السيارة</string>
<string name="hotspot_error_long_name">يجب ألا يتجاوز اسم نقطة الاتصال 32 بايت</string>
<string name="hotspot_error_invalid_character">يحتوي الاسم أو كلمة المرور على محرف غير صالح</string>
<string name="hotspot_error_password_length">يجب أن تتكون كلمة مرور نقطة الاتصال من 8 إلى 63 محرفًا</string>
</resources>
+13
View File
@@ -387,4 +387,17 @@
<string name="your_phone_your_drive">TU TELÉFONO. TU TRAYECTO.</string>
<string name="your_report_was_saved_to_the_selected_location">Tu informe se guardó en la ubicación seleccionada.</string>
<string name="cluster_waiting_for_map">DiPlay · esperando el mapa de CarPlay</string>
<string name="option_size_large">Grande</string>
<string name="option_size_medium">Mediano</string>
<string name="option_size_small">Pequeño</string>
<string name="option_theme_scenario">Escenario · mapa lateral</string>
<string name="option_theme_map">Mapa · mapa completo</string>
<string name="option_theme_simple">Simple · mapa lateral</string>
<string name="option_contrast_default">Predeterminado del tema</string>
<string name="option_contrast_light">Fondo claro · instrumentos oscuros</string>
<string name="option_contrast_dark">Fondo oscuro · instrumentos claros</string>
<string name="hotspot_error_empty_name">Introduce el nombre del punto de acceso del coche</string>
<string name="hotspot_error_long_name">El nombre del punto de acceso no debe superar los 32 bytes</string>
<string name="hotspot_error_invalid_character">El nombre o la contraseña contiene un carácter no válido</string>
<string name="hotspot_error_password_length">La contraseña del punto de acceso debe tener entre 8 y 63 caracteres</string>
</resources>
+13
View File
@@ -387,4 +387,17 @@
<string name="your_phone_your_drive">ВАШ ТЕЛЕФОН. ВАША ПОЕЗДКА.</string>
<string name="your_report_was_saved_to_the_selected_location">Отчёт сохранён в выбранном месте.</string>
<string name="cluster_waiting_for_map">DiPlay · ожидание карты CarPlay</string>
<string name="option_size_large">Большой</string>
<string name="option_size_medium">Средний</string>
<string name="option_size_small">Маленький</string>
<string name="option_theme_scenario">Сценарий · карта сбоку</string>
<string name="option_theme_map">Карта · полная карта</string>
<string name="option_theme_simple">Простой · карта сбоку</string>
<string name="option_contrast_default">По умолчанию для темы</string>
<string name="option_contrast_light">Светлый фон · тёмные приборы</string>
<string name="option_contrast_dark">Тёмный фон · светлые приборы</string>
<string name="hotspot_error_empty_name">Введите имя точки доступа автомобиля</string>
<string name="hotspot_error_long_name">Имя точки доступа не должно превышать 32 байта</string>
<string name="hotspot_error_invalid_character">Имя или пароль содержит недопустимый символ</string>
<string name="hotspot_error_password_length">Пароль точки доступа должен содержать от 8 до 63 символов</string>
</resources>
@@ -398,4 +398,17 @@
<string name="your_phone_your_drive">你的手机,畅享旅程。</string>
<string name="your_report_was_saved_to_the_selected_location">报告已保存到所选位置。</string>
<string name="cluster_waiting_for_map">DiPlay · 等待 CarPlay 地图</string>
<string name="option_size_large">大</string>
<string name="option_size_medium">中</string>
<string name="option_size_small">小</string>
<string name="option_theme_scenario">场景 · 侧边地图</string>
<string name="option_theme_map">地图 · 全屏地图</string>
<string name="option_theme_simple">简约 · 侧边地图</string>
<string name="option_contrast_default">主题默认</string>
<string name="option_contrast_light">浅色背景 · 深色仪表</string>
<string name="option_contrast_dark">深色背景 · 浅色仪表</string>
<string name="hotspot_error_empty_name">请输入车载热点名称</string>
<string name="hotspot_error_long_name">热点名称不能超过32字节</string>
<string name="hotspot_error_invalid_character">名称或密码包含无效字符</string>
<string name="hotspot_error_password_length">热点密码长度必须为8至63个字符</string>
</resources>
+13
View File
@@ -398,4 +398,17 @@
<string name="your_phone_your_drive">YOUR PHONE. YOUR DRIVE.</string>
<string name="your_report_was_saved_to_the_selected_location">Your report was saved to the selected location.</string>
<string name="cluster_waiting_for_map">DiPlay · waiting for the CarPlay map</string>
<string name="option_size_large">Large</string>
<string name="option_size_medium">Medium</string>
<string name="option_size_small">Small</string>
<string name="option_theme_scenario">Scenario · side map</string>
<string name="option_theme_map">Map · full map</string>
<string name="option_theme_simple">Simple · side map</string>
<string name="option_contrast_default">Theme default</string>
<string name="option_contrast_light">Light background · dark instruments</string>
<string name="option_contrast_dark">Dark background · light instruments</string>
<string name="hotspot_error_empty_name">Enter the car hotspot name</string>
<string name="hotspot_error_long_name">The hotspot name must be at most 32 bytes</string>
<string name="hotspot_error_invalid_character">The name or password contains an invalid character</string>
<string name="hotspot_error_password_length">The hotspot password must be 8–63 characters</string>
</resources>
@@ -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))
}
}
@@ -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
@@ -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,
@@ -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 <T> 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()
}
}
@@ -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
}
@@ -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<String>()
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))
}
}