feat(android): add system app language selection (#102876)

Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-07-09 15:27:36 +01:00 committed by GitHub
parent 8a8038170b
commit d9f8fc6322
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 681 additions and 232 deletions

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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"
}

View file

@ -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

View file

@ -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(

View file

@ -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 }

View file

@ -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),
)

View file

@ -0,0 +1 @@
unqualifiedResLocale=en

View file

@ -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"
}
}

View file

@ -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()

View file

@ -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" }