mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(android): add system app language selection (#102876)
Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
This commit is contained in:
parent
8a8038170b
commit
d9f8fc6322
13 changed files with 681 additions and 232 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
|
|||
- [x] Voice tab full functionality
|
||||
- [x] Screen tab full functionality
|
||||
- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs
|
||||
- [x] Per-app language selection for translated resources follows Android system settings and persistence
|
||||
|
||||
## Open in Android Studio
|
||||
|
||||
|
|
|
|||
|
|
@ -124,11 +124,48 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
bundle {
|
||||
language {
|
||||
// The in-app picker can select a locale outside the device language list.
|
||||
// Without a Play Core download path, every translated resource must stay installed.
|
||||
enableSplit = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters +=
|
||||
listOf(
|
||||
"ar",
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fa",
|
||||
"fr",
|
||||
"hi",
|
||||
"in",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-rBR",
|
||||
"ru",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-rCN",
|
||||
"zh-rTW",
|
||||
)
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
|
|
@ -200,6 +237,8 @@ dependencies {
|
|||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
// AppCompat owns per-app locale persistence and Activity recreation on API 31-32.
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@
|
|||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|microphone" />
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
package ai.openclaw.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.LocaleManagerCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
/** Keep these tags aligned with androidResources.localeFilters so Android never offers an unsupported locale. */
|
||||
internal enum class AppLanguage(
|
||||
val languageTag: String?,
|
||||
val displayName: String,
|
||||
) {
|
||||
System(languageTag = null, displayName = "System"),
|
||||
English(languageTag = "en", displayName = "English"),
|
||||
Arabic(languageTag = "ar", displayName = "العربية"),
|
||||
German(languageTag = "de", displayName = "Deutsch"),
|
||||
Spanish(languageTag = "es", displayName = "Español"),
|
||||
Persian(languageTag = "fa", displayName = "فارسی"),
|
||||
French(languageTag = "fr", displayName = "Français"),
|
||||
Hindi(languageTag = "hi", displayName = "हिन्दी"),
|
||||
Indonesian(languageTag = "id", displayName = "Bahasa Indonesia"),
|
||||
Italian(languageTag = "it", displayName = "Italiano"),
|
||||
Japanese(languageTag = "ja", displayName = "日本語"),
|
||||
Korean(languageTag = "ko", displayName = "한국어"),
|
||||
Dutch(languageTag = "nl", displayName = "Nederlands"),
|
||||
Polish(languageTag = "pl", displayName = "Polski"),
|
||||
PortugueseBrazil(languageTag = "pt-BR", displayName = "Português (Brasil)"),
|
||||
Russian(languageTag = "ru", displayName = "Русский"),
|
||||
Swedish(languageTag = "sv", displayName = "Svenska"),
|
||||
Thai(languageTag = "th", displayName = "ไทย"),
|
||||
Turkish(languageTag = "tr", displayName = "Türkçe"),
|
||||
Ukrainian(languageTag = "uk", displayName = "Українська"),
|
||||
Vietnamese(languageTag = "vi", displayName = "Tiếng Việt"),
|
||||
ChineseSimplified(languageTag = "zh-CN", displayName = "简体中文"),
|
||||
ChineseTraditional(languageTag = "zh-TW", displayName = "繁體中文"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromLanguageTag(languageTag: String?): AppLanguage {
|
||||
val locale = languageTag?.trim()?.takeIf(String::isNotEmpty)?.let(Locale::forLanguageTag)
|
||||
return locale?.let(::fromLocale) ?: System
|
||||
}
|
||||
|
||||
internal fun fromLocale(locale: Locale): AppLanguage? {
|
||||
val exactTag = locale.toLanguageTag()
|
||||
val exactMatch = entries.firstOrNull { language -> language.languageTag?.equals(exactTag, ignoreCase = true) == true }
|
||||
if (exactMatch != null) return exactMatch
|
||||
|
||||
return entries.firstOrNull { language ->
|
||||
val supportedLocale = language.languageTag?.let(Locale::forLanguageTag) ?: return@firstOrNull false
|
||||
LocaleListCompat.matchesLanguageAndScript(locale, supportedLocale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun appLanguageFromLocales(locales: LocaleListCompat): AppLanguage =
|
||||
if (locales.isEmpty) {
|
||||
AppLanguage.System
|
||||
} else {
|
||||
(0 until locales.size()).firstNotNullOfOrNull { index -> locales[index]?.let(AppLanguage::fromLocale) }
|
||||
?: AppLanguage.System
|
||||
}
|
||||
|
||||
internal fun currentAppLanguage(): AppLanguage = appLanguageFromLocales(AppCompatDelegate.getApplicationLocales())
|
||||
|
||||
internal fun localesForAppLanguage(language: AppLanguage): LocaleListCompat = language.languageTag?.let(LocaleListCompat::forLanguageTags) ?: LocaleListCompat.getEmptyLocaleList()
|
||||
|
||||
internal fun setAppLanguage(language: AppLanguage) {
|
||||
val locales = localesForAppLanguage(language)
|
||||
if (locales != AppCompatDelegate.getApplicationLocales()) {
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun currentSystemLanguageTag(context: Context): String {
|
||||
val systemLocales = LocaleManagerCompat.getSystemLocales(context)
|
||||
val locale = systemLocales[0] ?: Resources.getSystem().configuration.locales[0]
|
||||
return locale.toLanguageTag()
|
||||
}
|
||||
|
||||
internal fun appLanguageRowSubtitle(
|
||||
language: AppLanguage,
|
||||
systemLanguageTag: String,
|
||||
): String {
|
||||
val languageTag = language.languageTag
|
||||
if (languageTag != null) return "OpenClaw translations · $languageTag"
|
||||
return "Follow Android · $systemLanguageTag"
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ import ai.openclaw.app.ui.RootScreen
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
|
|
@ -38,7 +38,7 @@ import kotlinx.coroutines.withContext
|
|||
/**
|
||||
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
private var initializedViewModel: MainViewModel? = null
|
||||
|
|
@ -103,7 +103,9 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
override fun onStop() {
|
||||
foreground = false
|
||||
initializedViewModel?.setForeground(false)
|
||||
if (shouldNotifyRuntimeBackgrounded(isChangingConfigurations)) {
|
||||
initializedViewModel?.setForeground(false)
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +125,9 @@ class MainActivity : ComponentActivity() {
|
|||
initializedViewModel = readyViewModel
|
||||
readyViewModel.setForeground(foreground)
|
||||
startViewModelCollectors(readyViewModel)
|
||||
if (!readyViewModel.claimInitialIntentRouting()) {
|
||||
pendingIntentRouter.discardInitialIntent()
|
||||
}
|
||||
pendingIntentRouter.activate { initialIntent ->
|
||||
handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent)
|
||||
}
|
||||
|
|
@ -186,9 +191,13 @@ class MainActivity : ComponentActivity() {
|
|||
internal class MainActivityPendingIntentRouter {
|
||||
private var activated = false
|
||||
private var pendingIntent: Intent? = null
|
||||
private var pendingIntentIsInitial = false
|
||||
|
||||
fun setInitialIntent(intent: Intent?) {
|
||||
if (!activated) pendingIntent = intent
|
||||
if (!activated) {
|
||||
pendingIntent = intent
|
||||
pendingIntentIsInitial = true
|
||||
}
|
||||
}
|
||||
|
||||
fun onNewIntent(
|
||||
|
|
@ -200,6 +209,13 @@ internal class MainActivityPendingIntentRouter {
|
|||
return
|
||||
}
|
||||
pendingIntent = intent
|
||||
pendingIntentIsInitial = false
|
||||
}
|
||||
|
||||
fun discardInitialIntent() {
|
||||
if (activated || !pendingIntentIsInitial) return
|
||||
pendingIntent = null
|
||||
pendingIntentIsInitial = false
|
||||
}
|
||||
|
||||
fun activate(routeIntent: (Intent) -> Unit): Boolean {
|
||||
|
|
@ -207,10 +223,24 @@ internal class MainActivityPendingIntentRouter {
|
|||
activated = true
|
||||
pendingIntent?.let(routeIntent)
|
||||
pendingIntent = null
|
||||
pendingIntentIsInitial = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps launch intents one-shot across same-process Activity recreation, but not process death. */
|
||||
internal class MainActivityInitialIntentGate {
|
||||
private var claimed = false
|
||||
|
||||
fun claim(): Boolean {
|
||||
if (claimed) return false
|
||||
claimed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldNotifyRuntimeBackgrounded(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations
|
||||
|
||||
/** Preserves one-shot runtime UI startup while allowing screenshot fixtures to skip side effects. */
|
||||
internal class MainActivityRuntimeUiStarter {
|
||||
private var completed = false
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class MainViewModel(
|
|||
@Volatile private var foreground = false
|
||||
|
||||
@Volatile private var runtimeStartupQueued = false
|
||||
private val initialIntentGate = MainActivityInitialIntentGate()
|
||||
|
||||
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
|
||||
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
|
||||
|
|
@ -92,9 +93,20 @@ class MainViewModel(
|
|||
return runtime
|
||||
}
|
||||
|
||||
internal fun claimInitialIntentRouting(): Boolean = initialIntentGate.claim()
|
||||
|
||||
internal fun enterScreenshotFixtureMode(scene: AndroidScreenshotScene) {
|
||||
check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" }
|
||||
check(runtimeRef.value == null) { "Screenshot fixture mode must be selected before runtime startup" }
|
||||
runtimeRef.value?.let { runtime ->
|
||||
// The ViewModel survives locale recreation; keep the fixture runtime instead of
|
||||
// treating the restored Activity as a second fixture startup.
|
||||
check(runtime.mode == NodeRuntimeMode.ScreenshotFixture) {
|
||||
"Screenshot fixture mode must be selected before live runtime startup"
|
||||
}
|
||||
runtime.setForeground(foreground)
|
||||
_requestedHomeDestination.value = scene.homeDestination
|
||||
return
|
||||
}
|
||||
prefs.setOnboardingCompleted(true)
|
||||
prefs.setAppearanceThemeMode(AppearanceThemeMode.Dark)
|
||||
prefs.setDisplayName("Pixel")
|
||||
|
|
@ -302,6 +314,9 @@ class MainViewModel(
|
|||
* Starts runtime on foreground entry only after onboarding has completed.
|
||||
*/
|
||||
fun setForeground(value: Boolean) {
|
||||
// The ViewModel survives configuration recreation. Ignore the replacement
|
||||
// Activity's duplicate true edge so it cannot restart gateway work.
|
||||
if (foreground == value) return
|
||||
foreground = value
|
||||
if (
|
||||
shouldStartRuntimeOnForeground(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AndroidLicenseNotice
|
||||
import ai.openclaw.app.AppLanguage
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
|
|
@ -17,7 +18,10 @@ import ai.openclaw.app.LocationMode
|
|||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.appLanguageRowSubtitle
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.currentAppLanguage
|
||||
import ai.openclaw.app.currentSystemLanguageTag
|
||||
import ai.openclaw.app.gateway.GatewayRegistryEntryKind
|
||||
import ai.openclaw.app.gatewayTalkSetupDescription
|
||||
import ai.openclaw.app.gatewayTalkSetupStatusText
|
||||
|
|
@ -27,6 +31,7 @@ import ai.openclaw.app.loadAndroidLicenseNotices
|
|||
import ai.openclaw.app.locationModeAfterBackgroundSettings
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.photoReadPermissionsForRequest
|
||||
import ai.openclaw.app.setAppLanguage
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawIconBadge
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
|
|
@ -96,6 +101,7 @@ import androidx.compose.material.icons.filled.ContentCopy
|
|||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
|
|
@ -1385,12 +1391,16 @@ private fun AppearanceSettingsScreen(
|
|||
onBack: () -> Unit,
|
||||
) {
|
||||
val themeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var appLanguage by remember { mutableStateOf(currentAppLanguage()) }
|
||||
val systemLanguageTag = currentSystemLanguageTag(context)
|
||||
|
||||
SettingsDetailFrame(title = "Appearance", subtitle = "A calm, high-contrast OpenClaw interface.", icon = Icons.Default.Palette, onBack = onBack) {
|
||||
SettingsDetailFrame(title = "Appearance", subtitle = "Theme and translated Android text.", icon = Icons.Default.Palette, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Theme", appearanceThemeSummary(themeMode)),
|
||||
SettingsMetric("Language", appLanguage.displayName),
|
||||
SettingsMetric("Contrast", "High"),
|
||||
SettingsMetric("Typography", "Readable"),
|
||||
),
|
||||
|
|
@ -1405,9 +1415,59 @@ private fun AppearanceSettingsScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "App language", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "Changes Android text that OpenClaw has translated. Screens with English-only copy stay unchanged.",
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
AppLanguage.entries.forEachIndexed { index, language ->
|
||||
if (index > 0) HorizontalDivider(color = ClawTheme.colors.border)
|
||||
AppLanguageRow(
|
||||
language = language,
|
||||
selected = language == appLanguage,
|
||||
systemLanguageTag = systemLanguageTag,
|
||||
onClick = {
|
||||
appLanguage = language
|
||||
setAppLanguage(language)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppLanguageRow(
|
||||
language: AppLanguage,
|
||||
selected: Boolean,
|
||||
systemLanguageTag: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ClawListItem(
|
||||
title = language.displayName,
|
||||
subtitle = appLanguageRowSubtitle(language = language, systemLanguageTag = systemLanguageTag),
|
||||
leading = { ClawIconBadge(Icons.Default.Language) },
|
||||
trailing =
|
||||
if (selected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = ClawTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun appearanceThemeSummary(mode: AppearanceThemeMode): String = mode.displayLabel
|
||||
|
||||
internal fun appearanceThemeOptions(): List<String> = AppearanceThemeMode.entries.map { it.displayLabel }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import ai.openclaw.app.MainViewModel
|
|||
import ai.openclaw.app.NodeRuntime
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.currentAppLanguage
|
||||
import ai.openclaw.app.ui.chat.ChatScreen
|
||||
import ai.openclaw.app.ui.design.ClawBottomNav
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
|
|
@ -1469,6 +1470,7 @@ private fun SettingsShellScreen(
|
|||
SettingsDetailScreen(viewModel = viewModel, route = route, onBack = onBack)
|
||||
return
|
||||
}
|
||||
val appLanguage = currentAppLanguage()
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
|
||||
|
|
@ -1535,7 +1537,12 @@ private fun SettingsShellScreen(
|
|||
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
|
||||
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
|
||||
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
|
||||
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
|
||||
SettingsRow(
|
||||
"Appearance",
|
||||
"${appearanceThemeSummary(appearanceThemeMode)} · ${appLanguage.displayName}",
|
||||
Icons.Default.Palette,
|
||||
route = SettingsRoute.Appearance,
|
||||
),
|
||||
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
|
||||
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
|
||||
)
|
||||
|
|
|
|||
1
apps/android/app/src/main/res/resources.properties
Normal file
1
apps/android/app/src/main/res/resources.properties
Normal file
|
|
@ -0,0 +1 @@
|
|||
unqualifiedResLocale=en
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package ai.openclaw.app
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AppLanguageTest {
|
||||
@Test
|
||||
fun supportedLanguagesMatchPackagedTranslations() {
|
||||
assertEquals(
|
||||
setOf(
|
||||
"ar",
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fa",
|
||||
"fr",
|
||||
"hi",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
),
|
||||
AppLanguage.entries.mapNotNull(AppLanguage::languageTag).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyLanguageRoundTripsThroughAndroidLocales() {
|
||||
AppLanguage.entries.forEach { language ->
|
||||
assertEquals(language, appLanguageFromLocales(localesForAppLanguage(language)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemUsesAnEmptyLocaleList() {
|
||||
assertTrue(localesForAppLanguage(AppLanguage.System).isEmpty)
|
||||
assertEquals(AppLanguage.System, appLanguageFromLocales(LocaleListCompat.getEmptyLocaleList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun languageTagsNormalizeAtThePlatformBoundary() {
|
||||
assertEquals(AppLanguage.Indonesian, AppLanguage.fromLanguageTag("in"))
|
||||
assertEquals(AppLanguage.PortugueseBrazil, AppLanguage.fromLanguageTag("PT-br"))
|
||||
assertEquals(AppLanguage.English, AppLanguage.fromLanguageTag("en-US"))
|
||||
assertEquals(AppLanguage.German, AppLanguage.fromLanguageTag("de-DE"))
|
||||
assertEquals(AppLanguage.ChineseTraditional, AppLanguage.fromLanguageTag("zh-Hant-HK"))
|
||||
assertEquals(AppLanguage.System, AppLanguage.fromLanguageTag(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestedLocaleListUsesTheFirstSupportedLanguage() {
|
||||
assertEquals(
|
||||
AppLanguage.French,
|
||||
appLanguageFromLocales(LocaleListCompat.forLanguageTags("xx,fr-FR,de-DE")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generatedLocaleConfigMatchesPickerLanguages() {
|
||||
val parser = RuntimeEnvironment.getApplication().resources.getXml(R.xml._generated_res_locale_config)
|
||||
val packagedTags = mutableSetOf<String?>()
|
||||
var defaultLocaleTag: String? = null
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.eventType == XmlPullParser.START_TAG) {
|
||||
when (parser.name) {
|
||||
"locale-config" -> defaultLocaleTag = parser.getAttributeValue(androidNamespace, "defaultLocale")
|
||||
"locale" -> packagedTags += AppLanguage.fromLanguageTag(parser.getAttributeValue(androidNamespace, "name")).languageTag
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
|
||||
assertEquals("en", defaultLocaleTag)
|
||||
assertEquals(AppLanguage.entries.mapNotNull(AppLanguage::languageTag).toSet(), packagedTags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyPickerOptionHasAUniqueLabel() {
|
||||
val labels = AppLanguage.entries.map(AppLanguage::displayName)
|
||||
assertFalse(labels.any(String::isBlank))
|
||||
assertEquals(labels.size, labels.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemSubtitleReportsTheActualSystemLocale() {
|
||||
assertEquals("Follow Android · en-US", appLanguageRowSubtitle(AppLanguage.System, "en-US"))
|
||||
assertEquals("OpenClaw translations · ja", appLanguageRowSubtitle(AppLanguage.Japanese, "en-US"))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val androidNamespace = "http://schemas.android.com/apk/res/android"
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,47 @@ class MainActivityLifecycleTest {
|
|||
assertEquals(listOf(next), routed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingIntentRouterDiscardsOnlyRecreatedInitialIntent() {
|
||||
val router = MainActivityPendingIntentRouter()
|
||||
val routed = mutableListOf<Intent>()
|
||||
|
||||
router.setInitialIntent(Intent("recreated"))
|
||||
router.discardInitialIntent()
|
||||
|
||||
assertTrue(router.activate(routed::add))
|
||||
assertTrue(routed.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingIntentRouterKeepsNewIntentAcrossRecreationGate() {
|
||||
val router = MainActivityPendingIntentRouter()
|
||||
val routed = mutableListOf<Intent>()
|
||||
val replacement = Intent("replacement")
|
||||
|
||||
router.setInitialIntent(Intent("recreated"))
|
||||
router.onNewIntent(replacement, routed::add)
|
||||
router.discardInitialIntent()
|
||||
|
||||
assertTrue(router.activate(routed::add))
|
||||
assertEquals(listOf(replacement), routed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialIntentGateDistinguishesRecreationFromProcessRestoration() {
|
||||
val retainedGate = MainActivityInitialIntentGate()
|
||||
|
||||
assertTrue(retainedGate.claim())
|
||||
assertFalse(retainedGate.claim())
|
||||
assertTrue(MainActivityInitialIntentGate().claim())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runtimeStaysForegroundAcrossConfigurationRecreation() {
|
||||
assertFalse(shouldNotifyRuntimeBackgrounded(isChangingConfigurations = true))
|
||||
assertTrue(shouldNotifyRuntimeBackgrounded(isChangingConfigurations = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runtimeUiStarterWaitsForReadinessAndStartsOnce() {
|
||||
val starter = MainActivityRuntimeUiStarter()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
[versions]
|
||||
agp = "9.2.1"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.06.01"
|
||||
|
|
@ -30,6 +31,7 @@ serialization-json = "1.11.0"
|
|||
|
||||
[libraries]
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" }
|
||||
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" }
|
||||
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue