Fix app locale synchronization, missing translations and audio routing guards
This commit is contained in:
1 parent
c9aaf09810
commit
3273fbd428
17 files changed
+350
-62
No files matched your search
@@ -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
|
||||
var candidate: AudioTrack? = null
|
||||
val track = try {
|
||||
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))
|
||||
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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
File renamed without changes.
@@ -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,
|
||||
)
|
||||
if (candidate.state != AudioTrack.STATE_INITIALIZED) {
|
||||
candidate.release()
|
||||
candidate = AudioTrack.Builder()
|
||||
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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user