From 934759d1ccf50f8e8c4e2231a1cf12bbc12fbaa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 16 Dec 2025 16:49:56 +0100 Subject: [PATCH 1/6] feat: regional locales handling + support manifest --- compose/README.md | 4 +- core/api/android/core.api | 14 ++ core/api/jvm/core.api | 14 ++ .../appleMain/kotlin/io/tolgee/TolgeeApple.kt | 2 + .../io/tolgee/common/ExtendTolgee.apple.kt | 2 + .../src/commonMain/kotlin/io/tolgee/Tolgee.kt | 178 ++++++++++++++++-- .../kotlin/io/tolgee/api/TolgeeApi.kt | 64 ++++++- .../kotlin/io/tolgee/model/TolgeeManifest.kt | 30 +++ .../model/translation/TranslationICU.kt | 6 +- .../model/translation/TranslationSprintf.kt | 13 +- 10 files changed, 300 insertions(+), 27 deletions(-) create mode 100644 core/src/commonMain/kotlin/io/tolgee/model/TolgeeManifest.kt diff --git a/compose/README.md b/compose/README.md index 5096244..48b3032 100644 --- a/compose/README.md +++ b/compose/README.md @@ -161,7 +161,7 @@ fun LocaleSwitcher() { }.collectAsState(initial = tolgee.getLocale()) Row { - Text(text = stringResource(tolgee, R.string.selected_locale, currentLocale.language)) + Text(text = stringResource(tolgee, R.string.selected_locale, currentLocale.toTag("-"))) Button(onClick = { tolgee.setLocale("en") }) { Text("English") } @@ -188,7 +188,7 @@ fun LocaleAwareComponent() { }.collectAsState(initial = tolgee.getLocale()) // Your UI that depends on the current locale - Text(text = currentLocale.language) + Text(text = currentLocale.toTag("-")) } ``` diff --git a/core/api/android/core.api b/core/api/android/core.api index 7f521a8..d931e7d 100644 --- a/core/api/android/core.api +++ b/core/api/android/core.api @@ -14,6 +14,7 @@ public class io/tolgee/Tolgee { public static final fun new (Lkotlin/jvm/functions/Function1;)Lio/tolgee/TolgeeAndroid; public fun preload (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun removeChangeListener (Lio/tolgee/Tolgee$ChangeListener;)Z + protected final fun resolveLocale (Ljava/util/Locale;)Ljava/util/Locale; public fun setLocale (Ljava/lang/String;)Ljava/util/Locale; public fun setLocale (Ljava/util/Locale;)Ljava/util/Locale; public final fun t (Ljava/lang/String;)Ljava/lang/String; @@ -45,7 +46,9 @@ public final class io/tolgee/Tolgee$Config { public final fun component1 ()Ljava/util/Locale; public final fun component2 ()Lio/tolgee/Tolgee$Config$Network; public final fun component3 ()Lio/tolgee/Tolgee$Config$ContentDelivery; + public final fun component4 ()Ljava/util/List; public fun equals (Ljava/lang/Object;)Z + public final fun getAvailableLocales ()Ljava/util/List; public final fun getContentDelivery ()Lio/tolgee/Tolgee$Config$ContentDelivery; public final fun getLocale ()Ljava/util/Locale; public final fun getNetwork ()Lio/tolgee/Tolgee$Config$Network; @@ -55,12 +58,17 @@ public final class io/tolgee/Tolgee$Config { public final class io/tolgee/Tolgee$Config$Builder { public fun ()V + public final fun availableLocaleTags (Ljava/util/List;)Lio/tolgee/Tolgee$Config$Builder; + public final fun availableLocaleTags ([Ljava/lang/String;)Lio/tolgee/Tolgee$Config$Builder; + public final fun availableLocales (Ljava/util/List;)Lio/tolgee/Tolgee$Config$Builder; + public final fun availableLocales ([Ljava/util/Locale;)Lio/tolgee/Tolgee$Config$Builder; public final fun build ()Lio/tolgee/Tolgee$Config; public final fun contentDelivery (Lio/tolgee/Tolgee$Config$ContentDelivery;)Lio/tolgee/Tolgee$Config$Builder; public final fun contentDelivery (Ljava/lang/String;)Lio/tolgee/Tolgee$Config$Builder; public final fun contentDelivery (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$Builder; public final fun contentDelivery (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$Builder; public static synthetic fun contentDelivery$default (Lio/tolgee/Tolgee$Config$Builder;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/tolgee/Tolgee$Config$Builder; + public final fun getAvailableLocales ()Ljava/util/List; public final fun getContentDelivery ()Lio/tolgee/Tolgee$Config$ContentDelivery; public final fun getLocale ()Ljava/util/Locale; public final fun getNetwork ()Lio/tolgee/Tolgee$Config$Network; @@ -68,6 +76,7 @@ public final class io/tolgee/Tolgee$Config$Builder { public final fun locale (Ljava/util/Locale;)Lio/tolgee/Tolgee$Config$Builder; public final fun network (Lio/tolgee/Tolgee$Config$Network;)Lio/tolgee/Tolgee$Config$Builder; public final fun network (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$Builder; + public final fun setAvailableLocales (Ljava/util/List;)V public final fun setContentDelivery (Lio/tolgee/Tolgee$Config$ContentDelivery;)V public final fun setLocale (Ljava/util/Locale;)V public final fun setNetwork (Lio/tolgee/Tolgee$Config$Network;)V @@ -83,8 +92,10 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery { public final fun component2 ()Lkotlin/jvm/functions/Function1; public final fun component3 ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun component4 ()Lio/tolgee/Tolgee$Formatter; + public final fun component5 ()Ljava/lang/String; public fun equals (Ljava/lang/Object;)Z public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; + public final fun getManifestPath ()Ljava/lang/String; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; @@ -97,11 +108,14 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery$Builder { public final fun build ()Lio/tolgee/Tolgee$Config$ContentDelivery; public final fun formatter (Lio/tolgee/Tolgee$Formatter;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; + public final fun getManifestPath ()Ljava/lang/String; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; + public final fun manifestPath (Ljava/lang/String;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun path (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun setFormatter (Lio/tolgee/Tolgee$Formatter;)V + public final fun setManifestPath (Ljava/lang/String;)V public final fun setPath (Lkotlin/jvm/functions/Function1;)V public final fun setStorage (Lio/tolgee/storage/TolgeeStorageProvider;)V public final fun setUrl (Ljava/lang/String;)V diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index 688390e..84de02c 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -14,6 +14,7 @@ public class io/tolgee/Tolgee { public static final fun new (Lkotlin/jvm/functions/Function1;)Lio/tolgee/common/PlatformTolgee; public fun preload (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun removeChangeListener (Lio/tolgee/Tolgee$ChangeListener;)Z + protected final fun resolveLocale (Ljava/util/Locale;)Ljava/util/Locale; public fun setLocale (Ljava/lang/String;)Ljava/util/Locale; public fun setLocale (Ljava/util/Locale;)Ljava/util/Locale; public final fun t (Ljava/lang/String;)Ljava/lang/String; @@ -45,7 +46,9 @@ public final class io/tolgee/Tolgee$Config { public final fun component1 ()Ljava/util/Locale; public final fun component2 ()Lio/tolgee/Tolgee$Config$Network; public final fun component3 ()Lio/tolgee/Tolgee$Config$ContentDelivery; + public final fun component4 ()Ljava/util/List; public fun equals (Ljava/lang/Object;)Z + public final fun getAvailableLocales ()Ljava/util/List; public final fun getContentDelivery ()Lio/tolgee/Tolgee$Config$ContentDelivery; public final fun getLocale ()Ljava/util/Locale; public final fun getNetwork ()Lio/tolgee/Tolgee$Config$Network; @@ -55,12 +58,17 @@ public final class io/tolgee/Tolgee$Config { public final class io/tolgee/Tolgee$Config$Builder { public fun ()V + public final fun availableLocaleTags (Ljava/util/List;)Lio/tolgee/Tolgee$Config$Builder; + public final fun availableLocaleTags ([Ljava/lang/String;)Lio/tolgee/Tolgee$Config$Builder; + public final fun availableLocales (Ljava/util/List;)Lio/tolgee/Tolgee$Config$Builder; + public final fun availableLocales ([Ljava/util/Locale;)Lio/tolgee/Tolgee$Config$Builder; public final fun build ()Lio/tolgee/Tolgee$Config; public final fun contentDelivery (Lio/tolgee/Tolgee$Config$ContentDelivery;)Lio/tolgee/Tolgee$Config$Builder; public final fun contentDelivery (Ljava/lang/String;)Lio/tolgee/Tolgee$Config$Builder; public final fun contentDelivery (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$Builder; public final fun contentDelivery (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$Builder; public static synthetic fun contentDelivery$default (Lio/tolgee/Tolgee$Config$Builder;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/tolgee/Tolgee$Config$Builder; + public final fun getAvailableLocales ()Ljava/util/List; public final fun getContentDelivery ()Lio/tolgee/Tolgee$Config$ContentDelivery; public final fun getLocale ()Ljava/util/Locale; public final fun getNetwork ()Lio/tolgee/Tolgee$Config$Network; @@ -68,6 +76,7 @@ public final class io/tolgee/Tolgee$Config$Builder { public final fun locale (Ljava/util/Locale;)Lio/tolgee/Tolgee$Config$Builder; public final fun network (Lio/tolgee/Tolgee$Config$Network;)Lio/tolgee/Tolgee$Config$Builder; public final fun network (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$Builder; + public final fun setAvailableLocales (Ljava/util/List;)V public final fun setContentDelivery (Lio/tolgee/Tolgee$Config$ContentDelivery;)V public final fun setLocale (Ljava/util/Locale;)V public final fun setNetwork (Lio/tolgee/Tolgee$Config$Network;)V @@ -83,8 +92,10 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery { public final fun component2 ()Lkotlin/jvm/functions/Function1; public final fun component3 ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun component4 ()Lio/tolgee/Tolgee$Formatter; + public final fun component5 ()Ljava/lang/String; public fun equals (Ljava/lang/Object;)Z public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; + public final fun getManifestPath ()Ljava/lang/String; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; @@ -97,11 +108,14 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery$Builder { public final fun build ()Lio/tolgee/Tolgee$Config$ContentDelivery; public final fun formatter (Lio/tolgee/Tolgee$Formatter;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; + public final fun getManifestPath ()Ljava/lang/String; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; + public final fun manifestPath (Ljava/lang/String;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun path (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun setFormatter (Lio/tolgee/Tolgee$Formatter;)V + public final fun setManifestPath (Ljava/lang/String;)V public final fun setPath (Lkotlin/jvm/functions/Function1;)V public final fun setStorage (Lio/tolgee/storage/TolgeeStorageProvider;)V public final fun setUrl (Ljava/lang/String;)V diff --git a/core/src/appleMain/kotlin/io/tolgee/TolgeeApple.kt b/core/src/appleMain/kotlin/io/tolgee/TolgeeApple.kt index c37bca2..eefa01a 100644 --- a/core/src/appleMain/kotlin/io/tolgee/TolgeeApple.kt +++ b/core/src/appleMain/kotlin/io/tolgee/TolgeeApple.kt @@ -14,6 +14,7 @@ import platform.Foundation.NSString import platform.Foundation.countryCode import platform.Foundation.languageCode import platform.Foundation.localizedStringWithFormat +import platform.Foundation.scriptCode import platform.Foundation.variantCode /** @@ -118,6 +119,7 @@ data class TolgeeApple internal constructor( fun setLocale(nsLocale: NSLocale) = setLocale( createLocale( language = nsLocale.languageCode, + script = nsLocale.scriptCode?.ifBlank { null }, country = nsLocale.countryCode?.ifBlank { null }, variant = nsLocale.variantCode?.ifBlank { null } ) diff --git a/core/src/appleMain/kotlin/io/tolgee/common/ExtendTolgee.apple.kt b/core/src/appleMain/kotlin/io/tolgee/common/ExtendTolgee.apple.kt index c808977..8ddaa6f 100644 --- a/core/src/appleMain/kotlin/io/tolgee/common/ExtendTolgee.apple.kt +++ b/core/src/appleMain/kotlin/io/tolgee/common/ExtendTolgee.apple.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.IO import platform.Foundation.NSLocale import platform.Foundation.countryCode import platform.Foundation.languageCode +import platform.Foundation.scriptCode import platform.Foundation.variantCode import kotlin.coroutines.CoroutineContext @@ -66,6 +67,7 @@ actual typealias PlatformTolgee = TolgeeApple fun Tolgee.Config.Builder.locale(nsLocale: NSLocale) = locale( createLocale( language = nsLocale.languageCode, + script = nsLocale.scriptCode?.ifBlank { null }, country = nsLocale.countryCode?.ifBlank { null }, variant = nsLocale.variantCode?.ifBlank { null } ) diff --git a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt index 21f19d9..e672189 100644 --- a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt +++ b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt @@ -3,6 +3,7 @@ package io.tolgee import de.comahe.i18n4k.Locale import de.comahe.i18n4k.forLocaleTag import de.comahe.i18n4k.language +import de.comahe.i18n4k.toTag import dev.datlag.tooling.async.suspendCatching import io.ktor.client.* import io.ktor.client.engine.* @@ -14,6 +15,7 @@ import io.tolgee.common.platformHttpClient import io.tolgee.common.platformNetworkContext import io.tolgee.common.platformStorage import io.tolgee.model.TolgeeMessageParams +import io.tolgee.model.TolgeeManifest import io.tolgee.model.TolgeeTranslation import io.tolgee.storage.TolgeeStorageProvider import kotlinx.atomicfu.atomic @@ -72,6 +74,11 @@ open class Tolgee( */ private val translationsMutex = Mutex() + /** + * In-memory cache of loaded manifest. + */ + private val cachedManifest = atomic(null) + /** * A thread-safe atomic reference that holds the currently cached translation instance. * @@ -144,6 +151,80 @@ open class Tolgee( return changeListeners.remove(listener) } + /** + * Resolves the most appropriate available locale from the given locale. + * + * Resolution strategy: + * 1. If the exact locale is available, return it (e.g., "en-US" → "en-US") + * 2. Otherwise, try to find an exact match for the base language (e.g., "en-US" → "en") + * + * Available locales are determined from: + * 1. `config.availableLocales` (if manually specified) + * 2. `loadedManifest.value.availableLocales` (if fetched from CDN) + * 3. If neither is available, fallback is disabled (returns input locale as-is) + * + * @param locale The desired locale to resolve. Can be null. + * @return The resolved locale, or null if the input is null. + */ + protected fun resolveLocale(locale: Locale?): Locale? { + if (locale == null) return null + + // Get available locales from config or loaded manifest + val availableLocales = config.availableLocales + ?: cachedManifest.value?.availableLocales + + if (availableLocales == null) { + // Available locales not found in either config or manifest, + // fallback mechanism disabled - return input locale as-is + return locale + } + + // First, check if exact locale is available + if (locale in availableLocales) { + return locale + } + + // Fallback: Look for exact base language match only + // Find exact match for base language (e.g., "en" without region/script) + return availableLocales.firstOrNull { availableLocale -> + // Match only if: + // 1. The language matches + // 2. It's a base language (no region/script), checked by comparing tag to language + availableLocale.language == locale.language && + availableLocale.toTag("-") == availableLocale.language + } + } + + /** + * Loads manifest about available locales from the CDN or cache. + * + * This method fetches the manifest file which contains information + * about which locales are available in the project. The manifest is used + * for locale fallback (e.g., falling back from "en-US" to "en"). + * + * This method is thread-safe but does NOT use mutex locking since: + * - Multiple concurrent loads are acceptable (idempotent operation) + * - The atomic update ensures thread safety + * - Avoids blocking translation loading + */ + private suspend fun loadManifest() { + // Skip if manually configured + if (config.availableLocales != null) return + + // Skip if already loaded + if (cachedManifest.value != null) return + + withContext(config.network.context) { + // Try to fetch fresh manifest from CDN + val manifest = TolgeeApi.getManifest( + client = config.network.client, + config = config + ) + + cachedManifest.value = manifest + changeFlow.emit(Unit) + } + } /** * Loads translations for a given locale or the default locale if none is specified. @@ -154,14 +235,12 @@ open class Tolgee( * * @param locale The target locale to load translations for. Defaults to the current value from `localeFlow`. */ - private suspend fun loadTranslations( - locale: Locale? = localeFlow.value - ) = translationsMutex.withLock { + private suspend fun loadTranslations(locale: Locale?) = translationsMutex.withLock { currentTranslation(locale) ?: withContext(config.network.context) { TolgeeApi.getTranslations( client = config.network.client, config = config, - currentLanguage = locale?.language?.ifBlank { null }, + currentLanguage = locale?.toTag("-")?.ifBlank { null }, ).also { cachedTranslation.value = it changeFlow.emit(Unit) @@ -181,9 +260,7 @@ open class Tolgee( * @param locale The locale for which the translation is being requested. If null, uses the default or current value from `localeFlow`. * @return The `TolgeeTranslation` instance matching the provided locale, or null if no applicable translation is found. */ - private fun currentTranslation( - locale: Locale? = localeFlow.value, - ): TolgeeTranslation? { + private fun currentTranslation(locale: Locale?): TolgeeTranslation? { return cachedTranslation.value?.takeIf { if (locale != null) { it.hasLocale(locale) @@ -204,6 +281,8 @@ open class Tolgee( key: String, parameters: TolgeeMessageParams = TolgeeMessageParams.None ): Flow = localeFlow.mapLatest { locale -> + loadManifest() + val locale = resolveLocale(locale) val translation = currentTranslation(locale) ?: suspendCatching { loadTranslations(locale) }.getOrNull() ?: currentTranslation(locale) ?: return@mapLatest t(key, parameters) @@ -215,6 +294,8 @@ open class Tolgee( open fun tArrayFlow( key: String ): Flow> = localeFlow.mapLatest { locale -> + loadManifest() + val locale = resolveLocale(locale) val translation = currentTranslation(locale) ?: suspendCatching { loadTranslations(locale) }.getOrNull() ?: currentTranslation(locale) ?: return@mapLatest tArray(key) @@ -235,17 +316,19 @@ open class Tolgee( key: String, parameters: TolgeeMessageParams = TolgeeMessageParams.None ): String? { - val translation = currentTranslation() ?: return null + val locale = resolveLocale(localeFlow.value) + val translation = currentTranslation(locale) ?: return null - return translation.localized(key, parameters, localeFlow.value) + return translation.localized(key, parameters, locale) } open fun tArray( key: String ): List { - val translation = currentTranslation() ?: return emptyList() + val locale = resolveLocale(localeFlow.value) + val translation = currentTranslation(locale) ?: return emptyList() - return translation.stringArray(key, localeFlow.value) + return translation.stringArray(key, locale) } /** @@ -262,7 +345,10 @@ open class Tolgee( * operations. */ open suspend fun preload() { - suspendCatching { loadTranslations() } + suspendCatching { + loadManifest() + loadTranslations(resolveLocale(localeFlow.value)) + } } /** @@ -300,6 +386,7 @@ open class Tolgee( val locale: Locale?, val network: Network, val contentDelivery: ContentDelivery, + val availableLocales: List?, ) { /** @@ -319,6 +406,18 @@ open class Tolgee( */ var locale: Locale? = systemLocale + /** + * A list of available locales. If specified, the app won't try to fetch a manifest from the server. + * Instead, it will use the provided list of locales. Can be used to save on network requests. + * + * This list is used when determining the fallback language for translations. + * For example, if the requested language doesn't exist, but it's non-regional variant + * exists (`en-US` doesn't exist, but `en` does),we can use that instead. If we don't + * have a list of available locales and manifest fetching fails, the fallback mechanism + * will be disabled and only exactly matching locale will be used. + */ + var availableLocales: List? = null + /** * Represents the network configuration used within the `Builder`. * This property defines the HTTP client and coroutine context used for executing network operations. @@ -330,6 +429,7 @@ open class Tolgee( * @see Network.Builder */ var network: Network = Network() + /** * Represents the content delivery network (CDN) configuration associated with the builder. * @@ -361,6 +461,39 @@ open class Tolgee( */ fun locale(localeTag: String) = locale(forLocaleTag(localeTag)) + /** + * Sets the available locales configuration for the builder and returns the instance for further customization. + * + * @param locales A list of locales to be set for the configuration. + */ + fun availableLocales(locales: List) = apply { + this.availableLocales = locales + } + + /** + * Sets the available locales configuration for the builder and returns the instance for further customization. + * + * @param locales A list of locales to be set for the configuration. + */ + fun availableLocales(vararg locales: Locale) = apply { + this.availableLocales = locales.toList() + } + + /** + * Sets the available locales configuration for the builder and returns the instance for further customization. + * + * @param localeTags A list of locale strings in the format of a language tag (e.g., "en", "fr", "es"). + */ + fun availableLocaleTags(localeTags: List) = availableLocales(localeTags.map(::forLocaleTag)) + + /** + * Sets the available locales configuration for the builder and returns the instance for further customization. + * + * @param localeTags A list of locale strings in the format of a language tag (e.g., "en", "fr", "es"). + */ + fun availableLocaleTags(vararg localeTags: String) = availableLocales(localeTags.map(::forLocaleTag)) + + /** * Configures the network settings for the builder. * @@ -430,6 +563,7 @@ open class Tolgee( */ fun build(): Config = Config( locale = locale, + availableLocales = availableLocales, network = network, contentDelivery = contentDelivery, ) @@ -559,6 +693,7 @@ open class Tolgee( val path: (language: String) -> String = { "$it.json" }, val storage: TolgeeStorageProvider? = platformStorage, val formatter: Formatter = Formatter.Sprintf, + val manifestPath: String = path("manifest"), ) { /** * A builder class for constructing instances of `CDN` with configurable properties. @@ -587,6 +722,14 @@ open class Tolgee( */ var path: (language: String) -> String = { "$it.json" } + /** + * Represents the manifest file path within the CDN configuration. + * + * This variable holds the path to the manifest file within the CDN configuration. + * The default value is "manifest.json". + */ + var manifestPath: String? = null + /** * Represents the storage configuration for the Builder. * @@ -638,6 +781,16 @@ open class Tolgee( this.path = path } + /** + * Sets the manifest path for the CDN configuration. + * + * @param manifestPath The manifest path to be used for the CDN. + * @return The Builder instance with the updated manifest path. + */ + fun manifestPath(manifestPath: String) = apply { + this.manifestPath = manifestPath + } + /** * Configures the storage settings for the builder. * @@ -677,6 +830,7 @@ open class Tolgee( fun build(): ContentDelivery = ContentDelivery( url = url?.ifBlank { null }, path = path, + manifestPath = manifestPath ?: path("manifest"), storage = storage, formatter = formatter ) diff --git a/core/src/commonMain/kotlin/io/tolgee/api/TolgeeApi.kt b/core/src/commonMain/kotlin/io/tolgee/api/TolgeeApi.kt index bf8b0e4..48cb0d2 100644 --- a/core/src/commonMain/kotlin/io/tolgee/api/TolgeeApi.kt +++ b/core/src/commonMain/kotlin/io/tolgee/api/TolgeeApi.kt @@ -1,7 +1,7 @@ package io.tolgee.api import de.comahe.i18n4k.forLocaleTag -import de.comahe.i18n4k.language +import de.comahe.i18n4k.toTag import dev.datlag.tooling.async.suspendCatching import io.ktor.client.* import io.ktor.client.request.* @@ -11,6 +11,7 @@ import io.ktor.utils.io.core.toByteArray import io.tolgee.Tolgee import io.tolgee.common.keyData import io.tolgee.model.TolgeeKey +import io.tolgee.model.TolgeeManifest import io.tolgee.model.TolgeeTranslation import io.tolgee.model.translation.TranslationEmpty import kotlinx.collections.immutable.toImmutableList @@ -53,8 +54,8 @@ internal data object TolgeeApi { ): TolgeeTranslation { val storage = config.contentDelivery.storage val language = currentLanguage?.ifBlank { null } - ?: config.locale?.language?.ifBlank { null } - ?: Tolgee.systemLocale.language.ifBlank { null } + ?: config.locale?.toTag("-")?.ifBlank { null } + ?: Tolgee.systemLocale.toTag("-").ifBlank { null } ?: return TranslationEmpty val path = config.contentDelivery.path(language) @@ -70,6 +71,50 @@ internal data object TolgeeApi { return cached ?: TranslationEmpty } + /** + * Retrieves project manifest from the Content Delivery Network (CDN). + * + * @param client The HTTP client used to perform the network request. + * @param config The Tolgee configuration object containing CDN-related settings. + * @return A [TolgeeManifest] object containing available locales, or null if fetch fails. + */ + suspend fun getManifest( + client: HttpClient, + config: Tolgee.Config, + ): TolgeeManifest { + val storage = config.contentDelivery.storage + val path = config.contentDelivery.manifestPath + + // Try to fetch fresh manifest from CDN + val fresh = getTranslationFromCDN(client, config, path) + val decoded = fresh?.decodeManifest() + + if (decoded != null) { + // Cache the fresh manifest to storage + storage?.put(path, fresh.toByteArray()) + return decoded + } + + val cached = storage?.get(path)?.decodeToString()?.decodeManifest() + return cached ?: getFallbackManifest() + } + + /** + * Retrieves a fallback instance of [TolgeeManifest]. The fallback instance disables locale + * fallback mechanism. + * + * This method is used when manifest fetching fails, no further attempts to fetch manifest + * are made during the application's lifecycle. By setting the `locales` to `null`, it disables + * the locale fallback mechanism, where translations fallback from a regional variant to the base + * language (e.g., "en-US" to "en"). + * + * @return A [TolgeeManifest] instance with `locales` set to `null`, effectively disabling + * all locale fallback logic. + */ + private fun getFallbackManifest(): TolgeeManifest { + return TolgeeManifest(locales = null) + } + /** * Decodes a localization file's content in JSON format into a `TolgeeTranslation` object. * @@ -98,6 +143,19 @@ internal data object TolgeeApi { ) } + /** + * Decodes a JSON string into a `TolgeeManifest` object. + * + * @return The decoded manifest, or null if parsing fails. + */ + private fun String.decodeManifest(): TolgeeManifest? { + return try { + json.decodeFromString(this@decodeManifest) + } catch (e: Exception) { + null + } + } + /** * Retrieves translations from a Content Delivery Network (CDN) based on the specified configuration and language. * diff --git a/core/src/commonMain/kotlin/io/tolgee/model/TolgeeManifest.kt b/core/src/commonMain/kotlin/io/tolgee/model/TolgeeManifest.kt new file mode 100644 index 0000000..77f852a --- /dev/null +++ b/core/src/commonMain/kotlin/io/tolgee/model/TolgeeManifest.kt @@ -0,0 +1,30 @@ +package io.tolgee.model + +import de.comahe.i18n4k.Locale +import de.comahe.i18n4k.forLocaleTag +import kotlinx.serialization.Serializable + +/** + * Represents metadata about available translations in the project. + * + * This metadata is fetched from the CDN and contains information about + * which locales are available for translation fallback logic. + * + * @property locales List of locale tags (e.g., ["en", "en-US", "de", "fr-FR"]) + * that are available in this Tolgee project. If null, the + * fallback mechanism is disabled and only exact locale matches + * will be used. + */ +@Serializable +internal data class TolgeeManifest( + val locales: List? +) { + /** + * Converts the string locale tags to Locale objects. + * Lazily computed and cached for performance. + * Returns null if locales is null (fallback disabled). + */ + val availableLocales: List? by lazy { + locales?.map { forLocaleTag(it) } + } +} diff --git a/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationICU.kt b/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationICU.kt index e830662..ec8a39b 100644 --- a/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationICU.kt +++ b/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationICU.kt @@ -3,11 +3,11 @@ package io.tolgee.model.translation import de.comahe.i18n4k.Locale import de.comahe.i18n4k.forLocaleTag import de.comahe.i18n4k.i18n4kInitCldrPluralRules -import de.comahe.i18n4k.language import de.comahe.i18n4k.messages.MessageBundle import de.comahe.i18n4k.messages.formatter.MessageParameters import de.comahe.i18n4k.messages.providers.MessagesProvider import de.comahe.i18n4k.strings.LocalizedString +import de.comahe.i18n4k.toTag import io.tolgee.model.TolgeeKey import io.tolgee.model.TolgeeMessageParams import io.tolgee.model.TolgeeTranslation @@ -146,13 +146,13 @@ internal data class TranslationICU( */ override fun hasLocale(locale: Locale): Boolean { return locales.contains(locale) || locales.any { - it.language == locale.language + it.toTag("-") == locale.toTag("-") } } override fun stringArray(key: String, locale: Locale?): List { val foundTolgeeKey = stringArrayKeys.firstOrNull { it.keyName == key } ?: return emptyList() - return when (val data = foundTolgeeKey.translationForOrFirst(locale?.language)) { + return when (val data = foundTolgeeKey.translationForOrFirst(locale?.toTag("-"))) { is TolgeeKey.Data.Array -> data.array is TolgeeKey.Data.Text -> listOf(data.text) else -> emptyList() diff --git a/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationSprintf.kt b/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationSprintf.kt index ef85b7c..5d948ba 100644 --- a/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationSprintf.kt +++ b/core/src/commonMain/kotlin/io/tolgee/model/translation/TranslationSprintf.kt @@ -4,8 +4,7 @@ import de.comahe.i18n4k.Locale import de.comahe.i18n4k.cldr.plurals.PluralCategory import de.comahe.i18n4k.cldr.plurals.PluralRule import de.comahe.i18n4k.cldr.plurals.PluralRuleType -import de.comahe.i18n4k.createLocale -import de.comahe.i18n4k.language +import de.comahe.i18n4k.toTag import io.tolgee.common.sprintf import io.tolgee.model.TolgeeKey import io.tolgee.model.TolgeeMessageParams @@ -52,8 +51,8 @@ internal data class TranslationSprintf( } return when (val data = requestedKey.translationForOrFirst( - locale?.language?.ifBlank { null } - ?: this.usedLocale?.language?.ifBlank { null } + locale?.toTag("-")?.ifBlank { null } + ?: this.usedLocale?.toTag("-")?.ifBlank { null } )) { is TolgeeKey.Data.Text -> return data.text.sprintf(*args) is TolgeeKey.Data.Plural -> { @@ -68,7 +67,7 @@ internal data class TranslationSprintf( if (number == null) return PluralCategory.OTHER.id val locale = usedLocale ?: return PluralCategory.OTHER.id - val pluralRule = PluralRule.create(createLocale(locale.language), PluralRuleType.CARDINAL) + val pluralRule = PluralRule.create(locale, PluralRuleType.CARDINAL) val pluralCategory = when (number) { is Number -> pluralRule?.select(number) @@ -86,12 +85,12 @@ internal data class TranslationSprintf( * @return `true` if the given locale matches the currently used locale or shares the same language; `false` otherwise. */ override fun hasLocale(locale: Locale): Boolean { - return locale == this.usedLocale || locale.language == this.usedLocale?.language + return locale == this.usedLocale || locale.toTag("-") == this.usedLocale?.toTag("-") } override fun stringArray(key: String, locale: Locale?): List { val foundTolgeeKey = stringArrayKeys.firstOrNull { it.keyName == key } ?: return emptyList() - return when (val data = foundTolgeeKey.translationForOrFirst(locale?.language)) { + return when (val data = foundTolgeeKey.translationForOrFirst(locale?.toTag("-"))) { is TolgeeKey.Data.Array -> data.array is TolgeeKey.Data.Plural -> data.plurals.map { it.value } is TolgeeKey.Data.Text -> listOf(data.text) From 284fc135c7d45b18b36a06b1c2efb8c68ac2c6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 16 Dec 2025 19:24:58 +0100 Subject: [PATCH 2/6] feat: loading multiple languages into memory --- core/api/android/core.api | 5 + core/api/jvm/core.api | 5 + .../src/commonMain/kotlin/io/tolgee/Tolgee.kt | 123 ++++++++++++++---- .../io/tolgee/cache/TranslationCache.kt | 110 ++++++++++++++++ 4 files changed, 217 insertions(+), 26 deletions(-) create mode 100644 core/src/commonMain/kotlin/io/tolgee/cache/TranslationCache.kt diff --git a/core/api/android/core.api b/core/api/android/core.api index d931e7d..3bdf20d 100644 --- a/core/api/android/core.api +++ b/core/api/android/core.api @@ -93,9 +93,11 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery { public final fun component3 ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun component4 ()Lio/tolgee/Tolgee$Formatter; public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/Integer; public fun equals (Ljava/lang/Object;)Z public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; public final fun getManifestPath ()Ljava/lang/String; + public final fun getMaxLocalesInMemory ()Ljava/lang/Integer; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; @@ -109,13 +111,16 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery$Builder { public final fun formatter (Lio/tolgee/Tolgee$Formatter;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; public final fun getManifestPath ()Ljava/lang/String; + public final fun getMaxLocalesInMemory ()Ljava/lang/Integer; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; public final fun manifestPath (Ljava/lang/String;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; + public final fun maxLocalesInMemory (Ljava/lang/Integer;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun path (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun setFormatter (Lio/tolgee/Tolgee$Formatter;)V public final fun setManifestPath (Ljava/lang/String;)V + public final fun setMaxLocalesInMemory (Ljava/lang/Integer;)V public final fun setPath (Lkotlin/jvm/functions/Function1;)V public final fun setStorage (Lio/tolgee/storage/TolgeeStorageProvider;)V public final fun setUrl (Ljava/lang/String;)V diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index 84de02c..83a0913 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -93,9 +93,11 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery { public final fun component3 ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun component4 ()Lio/tolgee/Tolgee$Formatter; public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/Integer; public fun equals (Ljava/lang/Object;)Z public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; public final fun getManifestPath ()Ljava/lang/String; + public final fun getMaxLocalesInMemory ()Ljava/lang/Integer; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; @@ -109,13 +111,16 @@ public final class io/tolgee/Tolgee$Config$ContentDelivery$Builder { public final fun formatter (Lio/tolgee/Tolgee$Formatter;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun getFormatter ()Lio/tolgee/Tolgee$Formatter; public final fun getManifestPath ()Ljava/lang/String; + public final fun getMaxLocalesInMemory ()Ljava/lang/Integer; public final fun getPath ()Lkotlin/jvm/functions/Function1; public final fun getStorage ()Lio/tolgee/storage/TolgeeStorageProvider; public final fun getUrl ()Ljava/lang/String; public final fun manifestPath (Ljava/lang/String;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; + public final fun maxLocalesInMemory (Ljava/lang/Integer;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun path (Lkotlin/jvm/functions/Function1;)Lio/tolgee/Tolgee$Config$ContentDelivery$Builder; public final fun setFormatter (Lio/tolgee/Tolgee$Formatter;)V public final fun setManifestPath (Ljava/lang/String;)V + public final fun setMaxLocalesInMemory (Ljava/lang/Integer;)V public final fun setPath (Lkotlin/jvm/functions/Function1;)V public final fun setStorage (Lio/tolgee/storage/TolgeeStorageProvider;)V public final fun setUrl (Ljava/lang/String;)V diff --git a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt index e672189..ded3c99 100644 --- a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt +++ b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt @@ -9,6 +9,7 @@ import io.ktor.client.* import io.ktor.client.engine.* import io.tolgee.Tolgee.Companion.systemLocale import io.tolgee.api.TolgeeApi +import io.tolgee.cache.TranslationCache import io.tolgee.common.PlatformTolgee import io.tolgee.common.createPlatformTolgee import io.tolgee.common.platformHttpClient @@ -17,6 +18,7 @@ import io.tolgee.common.platformStorage import io.tolgee.model.TolgeeMessageParams import io.tolgee.model.TolgeeManifest import io.tolgee.model.TolgeeTranslation +import io.tolgee.model.translation.TranslationEmpty import io.tolgee.storage.TolgeeStorageProvider import kotlinx.atomicfu.atomic import kotlinx.coroutines.Dispatchers @@ -80,16 +82,22 @@ open class Tolgee( private val cachedManifest = atomic(null) /** - * A thread-safe atomic reference that holds the currently cached translation instance. + * LRU cache for storing multiple translation instances. * - * This property is used to store a `TolgeeTranslation` object representing the translations - * retrieved for a specific locale or the default locale. It supports concurrent read and - * write operations, ensuring synchronization and data integrity across threads or coroutines. + * This cache stores `TolgeeTranslation` objects for different locales, allowing + * switching between locales without reloading from network/storage. * - * The cached translation is updated during the `loadTranslations` function and is accessed - * in the `currentTranslation` function to retrieve the most recent translation for a given locale. + * The cache size is configured via `config.contentDelivery.maxLocalesInMemory`: + * - `null`: unlimited cache (no eviction) + * - `1`: default (single locale) + * - `2+`: multi-locale cache with LRU eviction + * + * The cache is updated during the `loadTranslations` function and is accessed + * in the `currentTranslation` function to retrieve translations for a given locale. */ - private val cachedTranslation = atomic(null) + private val translationCache: TranslationCache by lazy { + TranslationCache(config.contentDelivery.maxLocalesInMemory) + } /** * A reactive flow that holds the current locale used for translation operations. @@ -229,27 +237,47 @@ open class Tolgee( /** * Loads translations for a given locale or the default locale if none is specified. * - * This method attempts to retrieve the translation either from the currently cached data - * or by querying the Tolgee API. Newly retrieved translations are cached for future use. + * This method attempts to retrieve the translation either from the cache or by querying + * the Tolgee API. Newly retrieved translations are cached for future use according to + * the configured cache size limit. + * * Thread safety is ensured using a mutex lock. * * @param locale The target locale to load translations for. Defaults to the current value from `localeFlow`. + * @return The loaded or cached translation. */ private suspend fun loadTranslations(locale: Locale?) = translationsMutex.withLock { - currentTranslation(locale) ?: withContext(config.network.context) { - TolgeeApi.getTranslations( + val localeTag = locale?.toTag("-")?.ifBlank { null } + + // Check cache first + if (localeTag != null) { + translationCache.get(localeTag)?.let { + return@withLock it + } + } + + // Load from network/storage + withContext(config.network.context) { + val translation = TolgeeApi.getTranslations( client = config.network.client, config = config, - currentLanguage = locale?.toTag("-")?.ifBlank { null }, - ).also { - cachedTranslation.value = it - changeFlow.emit(Unit) - withContext(Dispatchers.Main) { - changeListeners.forEach { listener -> - listener.onTranslationsChanged() - } + currentLanguage = localeTag, + ) + + // Cache the loaded translation (skip TranslationEmpty) + if (localeTag != null && translation !is TranslationEmpty) { + translationCache.put(localeTag, translation) + } + + // Emit change events + changeFlow.emit(Unit) + withContext(Dispatchers.Main) { + changeListeners.forEach { listener -> + listener.onTranslationsChanged() } } + + return@withContext translation } } @@ -261,12 +289,10 @@ open class Tolgee( * @return The `TolgeeTranslation` instance matching the provided locale, or null if no applicable translation is found. */ private fun currentTranslation(locale: Locale?): TolgeeTranslation? { - return cachedTranslation.value?.takeIf { - if (locale != null) { - it.hasLocale(locale) - } else { - true - } + val localeTag = locale?.toTag("-")?.ifBlank { null } ?: return null + + return translationCache.get(localeTag)?.takeIf { + it.hasLocale(locale) } } @@ -694,6 +720,7 @@ open class Tolgee( val storage: TolgeeStorageProvider? = platformStorage, val formatter: Formatter = Formatter.Sprintf, val manifestPath: String = path("manifest"), + val maxLocalesInMemory: Int? = 1, ) { /** * A builder class for constructing instances of `CDN` with configurable properties. @@ -757,6 +784,18 @@ open class Tolgee( */ var formatter: Formatter = Formatter.Sprintf + /** + * Maximum number of locales to keep in memory cache. + * + * Uses LRU (Least Recently Used) eviction when the limit is reached. + * - `null`: unlimited cache (no eviction) + * - `1`: default (caches only one locale, same as current behavior) + * - `2+`: caches multiple locales with LRU eviction + * + * Each cached locale consumes memory proportional to its translation file size. + */ + var maxLocalesInMemory: Int? = 1 + /** * Sets the URL for the CDN configuration. * @@ -816,6 +855,37 @@ open class Tolgee( this.formatter = formatter } + /** + * Sets the maximum number of locales to keep in memory cache. + * + * Uses LRU (Least Recently Used) eviction when the limit is reached. + * Each cached locale consumes memory proportional to its translation file size. + * + * **Use Cases:** + * - Multi-language apps with frequent locale switching + * - Apps where users switch between 2-3 preferred languages + * + * **Default:** 1 (caches only one locale) + * + * @param max Maximum number of locales to cache. Use `null` for unlimited caching, + * or a positive integer (>= 1) for limited caching with LRU eviction. + * @return The Builder instance, enabling method chaining. + * @throws IllegalArgumentException if max < 1 (when not null) + * + * @sample + * ```kotlin + * contentDelivery { + * maxLocalesInMemory(3) // Cache up to 3 locales + * } + * ``` + */ + fun maxLocalesInMemory(max: Int?) = apply { + require(max == null || max >= 1) { + "maxLocalesInMemory must be null (unlimited) or at least 1, but was $max" + } + this.maxLocalesInMemory = max + } + /** * Builds and returns a configured `CDN` instance. * @@ -832,7 +902,8 @@ open class Tolgee( path = path, manifestPath = manifestPath ?: path("manifest"), storage = storage, - formatter = formatter + formatter = formatter, + maxLocalesInMemory = maxLocalesInMemory ) } diff --git a/core/src/commonMain/kotlin/io/tolgee/cache/TranslationCache.kt b/core/src/commonMain/kotlin/io/tolgee/cache/TranslationCache.kt new file mode 100644 index 0000000..38bb725 --- /dev/null +++ b/core/src/commonMain/kotlin/io/tolgee/cache/TranslationCache.kt @@ -0,0 +1,110 @@ +package io.tolgee.cache + +import io.tolgee.model.TolgeeTranslation + +/** + * Thread-safe LRU (Least Recently Used) cache for translation objects. + * + * This cache stores translations by locale tag and automatically evicts + * the least recently used entry when the maximum size is reached (if a limit is set). + * + * @property maxSize Maximum number of translations to cache. + * - `null`: unlimited cache (no eviction) + * - `>= 1`: limited cache with LRU eviction + */ +internal class TranslationCache( + private val maxSize: Int? +) { + init { + require(maxSize == null || maxSize >= 1) { + "maxSize must be null (unlimited) or at least 1, but was $maxSize" + } + } + + /** + * Internal cache entry that wraps a translation with its access counter. + * Higher counter values indicate more recent access. + */ + private data class CacheEntry( + val translation: TolgeeTranslation, + var accessCounter: Long + ) + + private val cache = mutableMapOf() + private var globalAccessCounter: Long = 0 + + /** + * Retrieves a translation from the cache and updates its access counter. + * + * @param localeTag The locale tag (e.g., "en-US", "fr") + * @return The cached translation, or null if not found + */ + fun get(localeTag: String): TolgeeTranslation? { + val entry = cache[localeTag] ?: return null + // Update access counter to mark as recently used + entry.accessCounter = ++globalAccessCounter + return entry.translation + } + + /** + * Adds a translation to the cache, evicting the LRU entry if needed. + * + * If the locale is already cached, this updates its access counter. + * If the cache is full (and has a size limit), the least recently used entry is evicted. + * + * @param localeTag The locale tag (e.g., "en-US", "fr") + * @param translation The translation object to cache + */ + fun put(localeTag: String, translation: TolgeeTranslation) { + val currentCounter = ++globalAccessCounter + + // Update existing entry + cache[localeTag]?.let { entry -> + entry.accessCounter = currentCounter + return + } + + // Evict LRU if at capacity (only if maxSize is set) + if (maxSize != null && cache.size >= maxSize) { + evictLRU() + } + + // Add new entry + cache[localeTag] = CacheEntry( + translation = translation, + accessCounter = currentCounter + ) + } + + /** + * Checks if a locale is currently cached. + * + * @param localeTag The locale tag to check + * @return true if the locale is cached, false otherwise + */ + fun contains(localeTag: String): Boolean = cache.containsKey(localeTag) + + /** + * Clears all cached translations. + */ + fun clear() = cache.clear() + + /** + * Returns the current number of cached translations. + * + * @return The number of entries in the cache + */ + fun size(): Int = cache.size + + /** + * Evicts the least recently used entry from the cache. + * + * This method is called internally when the cache reaches its maximum size. + * The entry with the lowest access counter is removed. + */ + private fun evictLRU() { + if (cache.isEmpty()) return + val lruKey = cache.minByOrNull { it.value.accessCounter }?.key + lruKey?.let { cache.remove(it) } + } +} From 6f6c6fd357f5fac8e28eb5d9974b6a61b0fc2b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 16 Dec 2025 20:22:10 +0100 Subject: [PATCH 3/6] feat: preload all languages --- .../kotlin/io/tolgee/TolgeeAndroid.kt | 23 ++++++++++ .../src/commonMain/kotlin/io/tolgee/Tolgee.kt | 44 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt b/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt index 459d57e..2bb2cc7 100644 --- a/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt +++ b/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt @@ -216,6 +216,29 @@ data class TolgeeAndroid internal constructor( preload() } + /** + * Preloads all available languages and their translations into memory. + * + * This is a convenience method that launches a coroutine in the provided [LifecycleOwner]'s + * lifecycle scope and calls the suspend [preloadAll] function. + * + * This method loads translations for all locales defined in the manifest or configuration. + * Translations are loaded into the LRU cache according to the configured cache size limit + * (see [Config.ContentDelivery.maxLocalesInMemory]). + * + * Use cases: + * - Applications that support frequent locale switching + * - Offline-first applications that want to cache multiple languages + * - Improving performance by preloading translations at app startup + * + * @param lifecycleOwner any [LifecycleOwner] to launch the coroutine from, e.g. Activity or Fragment + * @see preloadAll For the base suspend function + * @see preload For loading only the current locale + */ + fun preloadAll(lifecycleOwner: LifecycleOwner) = lifecycleOwner.lifecycleScope.launch { + preloadAll() + } + /** * A companion object for utility functions related to string resources and key retrieval. */ diff --git a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt index ded3c99..beec289 100644 --- a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt +++ b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt @@ -377,6 +377,50 @@ open class Tolgee( } } + /** + * Preloads all available languages and their translations into memory. + * + * This method loads translations for all locales defined in the manifest or configuration. + * Translations are loaded into the LRU cache according to the configured cache size limit + * (see [Config.ContentDelivery.maxLocalesInMemory]). + * + * The current locale is always loaded last to ensure it remains in the cache even when + * the cache size is limited and other locales might be evicted due to LRU policy. + * + * Use cases: + * - Applications that support frequent locale switching + * - Offline-first applications that want to cache multiple languages + * - Improving performance by preloading translations at app startup + * + * Individual locale loading failures are silently ignored, allowing other locales to load. + * + * @see preload For loading only the current locale + */ + open suspend fun preloadAll() { + suspendCatching { + loadManifest() + + val availableLocales = config.availableLocales + ?: cachedManifest.value?.availableLocales + ?: emptyList() + + val currentLocale = resolveLocale(localeFlow.value) + val otherLocales = availableLocales.filter { it != currentLocale } + + otherLocales.forEach { locale -> + suspendCatching { + loadTranslations(locale) + } + } + + if (currentLocale != null) { + suspendCatching { + loadTranslations(currentLocale) + } + } + } + } + /** * Sets the current locale for the Tolgee instance, updating it in the reactive locale flow. * From fb0f7b7b2a434f4b792989a4a892d39642331bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 18 Dec 2025 17:02:06 +0100 Subject: [PATCH 4/6] feat: auto translate keys during Android layout inflation --- .../kotlin/io/tolgee/TolgeeAndroid.kt | 51 ++++ .../kotlin/io/tolgee/TolgeeContextWrapper.kt | 40 ++- .../io/tolgee/TolgeeLayoutInflaterFactory.kt | 286 ++++++++++++++++++ .../kotlin/io/tolgee/TolgeeResources.kt | 13 +- core/src/androidMain/res/values/ids.xml | 7 + .../src/commonMain/kotlin/io/tolgee/Tolgee.kt | 5 + .../src/main/AndroidManifest.xml | 2 + .../demo/exampleandroid/MainActivity.kt | 48 +-- .../demo/exampleandroid/MainJavaActivity.java | 39 ++- .../demo/exampleandroid/MyApplication.kt | 1 + .../src/main/res/layout/activity_main.xml | 4 + .../src/main/res/values/strings.xml | 2 + 12 files changed, 459 insertions(+), 39 deletions(-) create mode 100644 core/src/androidMain/kotlin/io/tolgee/TolgeeLayoutInflaterFactory.kt create mode 100644 core/src/androidMain/res/values/ids.xml diff --git a/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt b/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt index 2bb2cc7..2cd66bf 100644 --- a/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt +++ b/core/src/androidMain/kotlin/io/tolgee/TolgeeAndroid.kt @@ -1,7 +1,9 @@ package io.tolgee +import android.app.Activity import android.content.Context import android.content.res.Resources +import android.view.View import androidx.annotation.AnyRes import androidx.annotation.ArrayRes import androidx.annotation.PluralsRes @@ -239,6 +241,55 @@ data class TolgeeAndroid internal constructor( preloadAll() } + /** + * Re-translates all views in the given view hierarchy that were automatically + * translated during layout inflation. + * + * This method walks the view hierarchy and re-applies translations to any views + * that have stored resource IDs from the [TolgeeLayoutInflaterFactory]. + * + * Use this after language changes if you prefer not to recreate the Activity. + * It provides a smoother UX than `Activity.recreate()` by avoiding the full + * Activity lifecycle restart. + * + * Example usage: + * ```kotlin + * lifecycleScope.launch { + * tolgee.changeFlow.collect { + * tolgee.retranslate(this@MainActivity) + * } + * } + * ``` + * + * @param rootView The root view to start re-translation from (typically content view) + * @see retranslate(Activity) For a convenience method that finds the content view automatically + */ + fun retranslate(rootView: View) { + TolgeeLayoutInflaterFactory.retranslateViewHierarchy(rootView, this) + } + + /** + * Re-translates all views in the Activity's content view. + * + * This is a convenience method that automatically finds the Activity's content view + * (android.R.id.content) and re-translates all views in that hierarchy. + * + * Example usage: + * ```kotlin + * lifecycleScope.launch { + * tolgee.changeFlow.collect { + * tolgee.retranslate(this@MainActivity) + * } + * } + * ``` + * + * @param activity The activity whose views should be re-translated + * @see retranslate(View) For the base method that accepts a root view + */ + fun retranslate(activity: Activity) { + activity.findViewById(android.R.id.content)?.let { retranslate(it) } + } + /** * A companion object for utility functions related to string resources and key retrieval. */ diff --git a/core/src/androidMain/kotlin/io/tolgee/TolgeeContextWrapper.kt b/core/src/androidMain/kotlin/io/tolgee/TolgeeContextWrapper.kt index 473df5f..12b0ee4 100644 --- a/core/src/androidMain/kotlin/io/tolgee/TolgeeContextWrapper.kt +++ b/core/src/androidMain/kotlin/io/tolgee/TolgeeContextWrapper.kt @@ -3,6 +3,7 @@ package io.tolgee import android.content.Context import android.content.ContextWrapper import android.content.res.Resources +import android.view.LayoutInflater import kotlinx.atomicfu.atomic /** @@ -10,29 +11,62 @@ import kotlinx.atomicfu.atomic * * This class intercepts calls to [getString] and attempts to load the string from Tolgee. * If no translation is loaded or found, it falls back to the default [Context.getString] implementation. + * Calls to [getText] are also intercepted, if the string is found in Tolgee. The returned value is not formatted. + * (behaves like [Context.getText]) + * + * Additionally, this wrapper installs a [TolgeeLayoutInflaterFactory] to automatically translate + * text attributes during layout inflation. * * @param base The base [Context] to wrap. * @param tolgee The Tolgee translation service used for retrieving localized strings. */ class TolgeeContextWrapper( val base: Context, - val tolgee: Tolgee + val tolgee: Tolgee, + val interceptGetString: Boolean = true, + val interceptGetText: Boolean = true, + val argumentLayoutInflater: Boolean = true ) : ContextWrapper(base) { private var baseRes by atomic(null) private var res by atomic(baseRes) + private var layoutInflater by atomic(null) override fun getResources(): Resources? { val base = super.getResources() ?: return res - if (res == null || baseRes != base) { - res = TolgeeResources(base, tolgee) + if ((res == null || baseRes != base) && (interceptGetString || interceptGetText)) { + res = TolgeeResources(base, tolgee, interceptGetString, interceptGetText) baseRes = base } return res ?: base } + override fun getSystemService(name: String): Any? { + if (LAYOUT_INFLATER_SERVICE == name) { + if (layoutInflater == null && tolgee is TolgeeAndroid && argumentLayoutInflater) { + val baseInflater = super.getSystemService(name) as? LayoutInflater + baseInflater?.let { + val cloned = it.cloneInContext(this) + installFactory2(cloned, tolgee) + layoutInflater = cloned + } + } + return layoutInflater ?: super.getSystemService(name) + } + return super.getSystemService(name) + } + + /** + * Installs the [TolgeeLayoutInflaterFactory] on the given LayoutInflater. + */ + private fun installFactory2(inflater: LayoutInflater, tolgee: TolgeeAndroid) { + val existingFactory = inflater.factory + val existingFactory2 = inflater.factory2 + inflater.factory2 = TolgeeLayoutInflaterFactory(tolgee, existingFactory, existingFactory2) + } + companion object { /** * Wraps the given [base] context with a [TolgeeContextWrapper] that uses the global singleton instance of Tolgee. diff --git a/core/src/androidMain/kotlin/io/tolgee/TolgeeLayoutInflaterFactory.kt b/core/src/androidMain/kotlin/io/tolgee/TolgeeLayoutInflaterFactory.kt new file mode 100644 index 0000000..a04b7ff --- /dev/null +++ b/core/src/androidMain/kotlin/io/tolgee/TolgeeLayoutInflaterFactory.kt @@ -0,0 +1,286 @@ +package io.tolgee + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import io.tolgee.mobilekotlinsdk.core.R + +/** + * A custom [LayoutInflater.Factory2] that automatically translates text attributes + * during view inflation. + * + * This factory intercepts view creation and applies Tolgee translations to text-related + * attributes (text, hint, contentDescription) automatically. It delegates to an existing + * factory (typically AppCompat's) to preserve compatibility with material components. + * Due to this, this factory behaves a little bit like `FactoryMerger`, as it has to sit + * in between existing factory and layout inflater. + * + * Resource IDs are stored in View tags for dynamic re-translation when the language changes. + * + * @param tolgee The Tolgee Android instance for translation lookups + * @param delegate The existing Factory to delegate view creation to (e.g., AppCompat) + * @param delegate2 The existing Factory2 to delegate view creation to (e.g., AppCompat) + */ +internal class TolgeeLayoutInflaterFactory( + private val tolgee: TolgeeAndroid, + private val delegate: LayoutInflater.Factory?, + private val delegate2: LayoutInflater.Factory2? +) : LayoutInflater.Factory2 { + + override fun onCreateView( + parent: View?, + name: String, + context: Context, + attrs: AttributeSet + ): View? { + // Delegate to existing factory first (AppCompat, etc.) + val view = delegate2?.onCreateView(parent, name, context, attrs) + ?: delegate2?.onCreateView(name, context, attrs) + ?: delegate?.onCreateView(name, context, attrs) + ?: createViewWithFallback(name, context, attrs) // Fallback for bare Activity and custom views + + // Apply Tolgee translations to the created view + view?.let { applyTranslations(it, context, attrs) } + + return view + } + + override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { + // Delegate to existing factory first (AppCompat, etc.) + val view = delegate2?.onCreateView(name, context, attrs) + ?: delegate?.onCreateView(name, context, attrs) + ?: createViewWithFallback(name, context, attrs) // Fallback for bare Activity and custom views + + // Apply Tolgee translations to the created view + view?.let { applyTranslations(it, context, attrs) } + + return view + } + + /** + * Creates a view when all delegates return null. + * + * This handles edge cases like: + * - Bare Activity (no AppCompat) + * - Custom views with translatable attributes + * - Any other scenario where existing factories don't create the view + */ + private fun createViewWithFallback( + name: String, + context: Context, + attrs: AttributeSet + ): View? { + return try { + if (name.contains('.')) { + // Fully qualified name: com.example.CustomView + createViewByClassName(name, context, attrs) + } else { + // Simple name: TextView, Button, etc. + createViewWithPrefix(name, context, attrs) + } + } catch (e: Exception) { + // Let LayoutInflater's default mechanism handle it + null + } + } + + /** + * Creates a view using its fully qualified class name. + */ + private fun createViewByClassName( + className: String, + context: Context, + attrs: AttributeSet + ): View? { + return try { + val clazz = context.classLoader.loadClass(className).asSubclass(View::class.java) + val constructor = clazz.getConstructor(Context::class.java, AttributeSet::class.java) + constructor.newInstance(context, attrs) + } catch (e: Exception) { + null + } + } + + /** + * Creates a view by trying standard Android prefixes. + * + * Tries in order: + * 1. android.widget.* (TextView, Button, EditText, etc.) + * 2. android.webkit.* (WebView) + * 3. android.app.* (Fragment) + */ + private fun createViewWithPrefix( + name: String, + context: Context, + attrs: AttributeSet + ): View? { + for (prefix in CLASS_PREFIXES) { + try { + val clazz = context.classLoader.loadClass(prefix + name).asSubclass(View::class.java) + val constructor = clazz.getConstructor(Context::class.java, AttributeSet::class.java) + return constructor.newInstance(context, attrs) + } catch (e: ClassNotFoundException) { + // Try next prefix + continue + } + } + return null + } + + /** + * Extracts string resource IDs from view attributes and applies translations. + * + * This method: + * 1. Extracts resource IDs from text-related attributes + * 2. Stores them in View tags for later re-translation + * 3. Applies immediate translations using tolgee.t() + * 4. Skips parameterized strings (with format placeholders) + */ + private fun applyTranslations(view: View, context: Context, attrs: AttributeSet) { + val typedArray = context.obtainStyledAttributes(attrs, TRANSLATABLE_ATTRS) + try { + // Extract resource IDs from attributes + val textResId = typedArray.getResourceId(ATTR_TEXT_INDEX, 0) + val hintResId = typedArray.getResourceId(ATTR_HINT_INDEX, 0) + val contentDescResId = typedArray.getResourceId(ATTR_CONTENT_DESC_INDEX, 0) + + // Apply text translation + if (textResId != 0 && !hasFormatPlaceholders(context, textResId)) { + view.setTag(R.id.tolgee_text_res_id, textResId) + applyTextTranslation(view, textResId, tolgee) + } + + // Apply hint translation + if (hintResId != 0 && !hasFormatPlaceholders(context, hintResId)) { + view.setTag(R.id.tolgee_hint_res_id, hintResId) + applyHintTranslation(view, hintResId, tolgee) + } + + // Apply content description translation + if (contentDescResId != 0 && !hasFormatPlaceholders(context, contentDescResId)) { + view.setTag(R.id.tolgee_content_desc_res_id, contentDescResId) + applyContentDescriptionTranslation(view, contentDescResId, tolgee) + } + } finally { + typedArray.recycle() + } + } + + /** + * Detects if a string resource contains format placeholders. + * + * Parameterized strings (with %1$s, %d, etc.) cannot be auto-translated + * because the format arguments are only available at runtime. + * + * @return true if the string contains format placeholders, false otherwise + */ + private fun hasFormatPlaceholders(context: Context, resId: Int): Boolean { + return try { + val text = context.resources.getString(resId) + FORMAT_PLACEHOLDER_REGEX.containsMatchIn(text) + } catch (e: Exception) { + // If we can't read the string, skip auto-translation + true + } + } + + companion object { + /** + * Standard Android class prefixes for view resolution. + * Used when creating views with simple names (TextView, Button, etc.) + */ + private val CLASS_PREFIXES = arrayOf( + "android.widget.", + "android.webkit.", + "android.app." + ) + + /** + * Array of translatable attribute IDs we want to intercept. + */ + private val TRANSLATABLE_ATTRS = intArrayOf( + android.R.attr.text, + android.R.attr.hint, + android.R.attr.contentDescription + ) + + /** + * Indices into TRANSLATABLE_ATTRS array + */ + private const val ATTR_TEXT_INDEX = 0 + private const val ATTR_HINT_INDEX = 1 + private const val ATTR_CONTENT_DESC_INDEX = 2 + + /** + * Regex pattern to detect format placeholders like %1$s, %d, %s, etc. + */ + private val FORMAT_PLACEHOLDER_REGEX = Regex("""%(\d+\$)?[sdxXfegGc]""") + + /** + * Re-translates all views in the given view hierarchy. + * + * This method recursively walks the view hierarchy and re-translates + * any views that were automatically translated during inflation. + * + * @param rootView The root view to start re-translation from + * @param tolgee The Tolgee Android instance for translation lookups + */ + fun retranslateViewHierarchy(rootView: View, tolgee: TolgeeAndroid) { + retranslateView(rootView, tolgee) + + if (rootView is ViewGroup) { + for (i in 0 until rootView.childCount) { + retranslateViewHierarchy(rootView.getChildAt(i), tolgee) + } + } + } + + /** + * Re-translates a single view using its stored resource IDs. + */ + private fun retranslateView(view: View, tolgee: TolgeeAndroid) { + // Re-translate text + (view.getTag(R.id.tolgee_text_res_id) as? Int)?.let { resId -> + applyTextTranslation(view, resId, tolgee) + } + + // Re-translate hint + (view.getTag(R.id.tolgee_hint_res_id) as? Int)?.let { resId -> + applyHintTranslation(view, resId, tolgee) + } + + // Re-translate content description + (view.getTag(R.id.tolgee_content_desc_res_id) as? Int)?.let { resId -> + applyContentDescriptionTranslation(view, resId, tolgee) + } + } + + /** + * Applies text translation to a view. + */ + private fun applyTextTranslation(view: View, resId: Int, tolgee: TolgeeAndroid) { + when (view) { + is TextView -> view.text = tolgee.t(view.resources, resId) + } + } + + /** + * Applies hint translation to a view. + */ + private fun applyHintTranslation(view: View, resId: Int, tolgee: TolgeeAndroid) { + when (view) { + is TextView -> view.hint = tolgee.t(view.resources, resId) + } + } + + /** + * Applies content description translation to a view. + */ + private fun applyContentDescriptionTranslation(view: View, resId: Int, tolgee: TolgeeAndroid) { + view.contentDescription = tolgee.t(view.resources, resId) + } + } +} diff --git a/core/src/androidMain/kotlin/io/tolgee/TolgeeResources.kt b/core/src/androidMain/kotlin/io/tolgee/TolgeeResources.kt index fc38f41..cf2ca19 100644 --- a/core/src/androidMain/kotlin/io/tolgee/TolgeeResources.kt +++ b/core/src/androidMain/kotlin/io/tolgee/TolgeeResources.kt @@ -52,7 +52,9 @@ import java.io.InputStream @Suppress("DEPRECATION") internal class TolgeeResources( val base: Resources, - val tolgee: Tolgee + val tolgee: Tolgee, + val interceptGetString: Boolean = true, + val interceptGetText: Boolean = true ) : Resources(base.assets, base.displayMetrics, base.configuration) { /** @@ -60,38 +62,47 @@ internal class TolgeeResources( */ override fun getString(@StringRes id: Int): String { + if (!interceptGetString) return base.getString(id) return base.getStringT(tolgee, id) } override fun getString(@StringRes id: Int, vararg formatArgs: Any?): String { + if (!interceptGetString) return base.getString(id, *formatArgs) return base.getStringT(tolgee, id, *formatArgs.filterNotNull().toTypedArray()) } override fun getQuantityString(@PluralsRes id: Int, quantity: Int): String { + if (!interceptGetString) return base.getQuantityString(id, quantity) return base.getQuantityStringT(tolgee, id, quantity) } override fun getQuantityString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any?): String { + if (!interceptGetString) return base.getQuantityString(id, quantity, *formatArgs) return base.getQuantityStringT(tolgee, id, quantity, *formatArgs.filterNotNull().toTypedArray()) } override fun getStringArray(@ArrayRes id: Int): Array { + if (!interceptGetString) return base.getStringArray(id) return base.getStringArrayT(tolgee, id) } override fun getText(@StringRes id: Int): CharSequence { + if (!interceptGetText) return base.getText(id) return base.getTextT(tolgee, id) } override fun getQuantityText(@PluralsRes id: Int, quantity: Int): CharSequence { + if (!interceptGetText) return base.getQuantityText(id, quantity) return base.getQuantityTextT(tolgee, id, quantity) } override fun getTextArray(id: Int): Array { + if (!interceptGetText) return base.getTextArray(id) return base.getTextArrayT(tolgee, id) } override fun getText(@StringRes id: Int, def: CharSequence?): CharSequence? { + if (!interceptGetText) return base.getText(id, def) return base.getTextT(tolgee, id, def) } diff --git a/core/src/androidMain/res/values/ids.xml b/core/src/androidMain/res/values/ids.xml new file mode 100644 index 0000000..db44abb --- /dev/null +++ b/core/src/androidMain/res/values/ids.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt index beec289..52cfcd8 100644 --- a/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt +++ b/core/src/commonMain/kotlin/io/tolgee/Tolgee.kt @@ -231,6 +231,11 @@ open class Tolgee( cachedManifest.value = manifest changeFlow.emit(Unit) + withContext(Dispatchers.Main) { + changeListeners.forEach { listener -> + listener.onTranslationsChanged() + } + } } } diff --git a/demo/exampleandroid/src/main/AndroidManifest.xml b/demo/exampleandroid/src/main/AndroidManifest.xml index 1dd6e4a..6dc035b 100644 --- a/demo/exampleandroid/src/main/AndroidManifest.xml +++ b/demo/exampleandroid/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:supportsRtl="true"> @@ -22,6 +23,7 @@ diff --git a/demo/exampleandroid/src/main/java/io/tolgee/demo/exampleandroid/MainActivity.kt b/demo/exampleandroid/src/main/java/io/tolgee/demo/exampleandroid/MainActivity.kt index 1dd0afb..4e3208e 100644 --- a/demo/exampleandroid/src/main/java/io/tolgee/demo/exampleandroid/MainActivity.kt +++ b/demo/exampleandroid/src/main/java/io/tolgee/demo/exampleandroid/MainActivity.kt @@ -26,8 +26,14 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { tolgee.changeFlow.collect { - // we want to reload activity after a language change - recreate() + // Re-translate views without recreating the Activity for smoother UX + tolgee.retranslate(this@MainActivity) // or recreate() for more complex activities + + // Make sure the app title is updated + setTitle(R.string.app_name) + + // Still need to manually update parameterized strings and plurals + updateParameterizedStrings() } } @@ -36,36 +42,36 @@ class MainActivity : ComponentActivity() { // Make sure the app title stays updated setTitle(R.string.app_name) - val name = findViewById(R.id.app_name_text) - val basic = findViewById(R.id.basic_text) - val parameter = findViewById(R.id.parameterized_text) - val plural = findViewById(R.id.plural_text) - val array = findViewById(R.id.array_text) - val buttonEn = findViewById