core-locale: shared per-app language plumbing (AppLanguage)

Android-library module, drawn from the byte-identical AppLanguage in Agendula
and Calendula: read the shipped languages from res/xml/locales_config.xml,
get/set the applied language via AppCompatDelegate, and render each language's
autonym. The locales_config resource id is now a parameter, so the module is
app-agnostic. appcompat only, no Compose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:12:16 +02:00
parent 367a8ff8af
commit 4eb9809993
6 changed files with 150 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
// core-locale — per-app language plumbing shared across the family: read the
// shipped languages from res/xml/locales_config.xml, get/set the applied
// language via AppCompatDelegate, and render each language's autonym. App-
// agnostic — the consuming app passes its own locales_config resource id; no
// Compose, no DataStore.
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "de.jeanlucmakiola.floret.locale"
compileSdk = 37
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
// appcompat brings AppCompatDelegate plus its core (LocaleListCompat) and
// annotation (XmlRes) transitives.
implementation(libs.androidx.appcompat)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
}

View File

@@ -0,0 +1,80 @@
package de.jeanlucmakiola.floret.locale
import android.content.Context
import androidx.annotation.XmlRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/**
* Per-app language via AppCompatDelegate, driven by an app's
* res/xml/locales_config.xml.
*
* That file is the single source of truth for which languages an app ships:
* dropping in a values-<tag> translation and adding a matching `<locale>` entry
* makes the language show up here and in the system per-app-language settings,
* with no other code change. The system-default choice is represented as `null`.
*
* On API 33+ this delegates to the platform per-app-languages API; below that
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
* service), so we don't mirror it in DataStore. Setting a locale recreates the
* activity, which re-reads the current value for the picker.
*/
object AppLanguage {
/**
* The BCP-47 tags the app ships translations for, in declaration order, as
* listed in [localesConfig] (the app's `res/xml/locales_config.xml`). Returns
* whatever could be parsed; a missing or malformed config yields an empty
* list (the picker then offers only the system-default entry rather than
* crashing).
*/
fun supportedTags(context: Context, @XmlRes localesConfig: Int): List<String> {
val tags = mutableListOf<String>()
val parser = context.resources.getXml(localesConfig)
try {
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
}
event = parser.next()
}
} catch (_: Exception) {
// Fall back to whatever was parsed before the failure.
} finally {
parser.close()
}
return tags
}
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
fun currentTag(): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
}
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
fun apply(tag: String?) {
val locales = if (tag == null) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
/**
* The autonym for a tag — the language's own name in its own script, e.g.
* "Deutsch", "English", "Français" — so users find their language regardless
* of the current UI language. Capitalised per the language's own rules.
*/
fun displayName(tag: String): String {
val locale = Locale.forLanguageTag(tag)
return locale.getDisplayName(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
}
}

View File

@@ -0,0 +1,20 @@
package de.jeanlucmakiola.floret.locale
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class AppLanguageTest {
@Test
fun `autonym is the language's own name`() {
assertThat(AppLanguage.displayName("en")).isEqualTo("English")
assertThat(AppLanguage.displayName("de")).isEqualTo("Deutsch")
}
@Test
fun `autonym is capitalised per the language's own rules`() {
// French autonyms are lower-case in CLDR ("français"); the picker shows
// them title-cased so each language reads as a proper name.
assertThat(AppLanguage.displayName("fr")).isEqualTo("Français")
}
}