A production-ready Android application demonstrating advanced localization techniques including runtime language switching, locale-aware datetime formatting with ICU skeletons, and intelligent caching—all built with Jetpack Compose.
This project showcases best practices for implementing localization in modern Android applications. Beyond basic language switching, it demonstrates production-ready patterns including:
- ⚡ Zero-restart language switching using AndroidX AppCompat's per-app language preferences API
- 🕐 Locale-aware datetime formatting with ICU skeleton patterns and intelligent caching
- 🎨 Material 3 design with edge-to-edge display
- 🔄 Follow system locale option for seamless integration with device settings
- 🚀 Runtime Language Switching: Change app language instantly without restarting
- ⚡ DateTimeFormatter Caching: Production-ready cache system for optimal performance
- 🌍 ICU Skeleton Support: Locale-aware date/time formatting using ICU skeleton patterns
- 📱 Modern Jetpack Compose UI: Pure Compose implementation with Material 3
- 🔀 Follow System Option: Seamlessly follow device locale settings
- 🎯 Per-App Language Settings: Uses AndroidX AppCompat's
setApplicationLocales()
API (API 27+) - 📊 Live Locale Information: Real-time display of current locale details
- 🌐 Accept-Language Header Demo: HTTP request demonstration with automatic locale-aware Accept-Language headers
- English (en)
- Vietnamese (vi-VN)
The app dynamically displays available languages from BuildConfig
and highlights the currently selected one.
Jetpack-Compose-Localization-Demo.mp4
git clone https://github.com/hoc081098/Jetpack-Compose-Localization.git
cd Jetpack-Compose-Localization
./gradlew installDebug
Run the app and tap on a language to see instant language switching with locale-aware datetime formatting!
- Min SDK: API 27 (Android 8.1)
- Target/Compile SDK: API 36 (Android 14)
- Kotlin: 2.0.21
- Gradle: 8.13.0
- Java: 11+
- Jetpack Compose - Modern declarative UI
- Material 3 - Material Design 3 components
- AndroidX AppCompat - Per-app language preferences API
- AndroidX Lifecycle - Lifecycle-aware components
- Java Time API - Modern date/time handling with ICU patterns
- Retrofit - Type-safe HTTP client for network requests
- Moshi - Modern JSON library for Kotlin
- OkHttp - HTTP client with interceptor support
app/src/main/
├── java/com/hoc081098/jetpackcomposelocalization/
│ ├── MainActivity.kt # Main activity with language switching
│ ├── DemoAcceptLanguageHeader.kt # Accept-Language header demo
│ ├── MyApplication.kt # Application class for initialization
│ ├── data/
│ │ ├── AcceptedLanguageInterceptor.kt # OkHttp interceptor for Accept-Language
│ │ ├── ApiService.kt # Retrofit API interface
│ │ └── NetworkServiceLocator.kt # Network service configuration
│ └── ui/
│ ├── locale/
│ │ ├── AppLocaleManager.kt # Locale management and state
│ │ └── currentLocale.kt # Composable to get current locale
│ ├── text/
│ │ └── DateTimeFormatterCache.kt # 🔥 Intelligent formatter caching
│ ├── time/
│ │ └── Instant.kt # Extension functions for time formatting
│ └── theme/
│ ├── Color.kt # Color definitions
│ ├── Theme.kt # Material Theme configuration
│ └── Type.kt # Typography definitions
└── res/
├── values/ # Default resources (English)
│ └── strings.xml
└── values-vi/ # Vietnamese resources
└── strings.xml
- Android Studio (latest version)
- JDK 11 or higher
- Android emulator or physical device
# Clone the repository
git clone https://github.com/hoc081098/Jetpack-Compose-Localization.git
cd Jetpack-Compose-Localization
# Build and install
./gradlew build
./gradlew installDebug
Or open in Android Studio → Sync → Run (Shift + F10)
The app uses AppLocaleManager
with AndroidX AppCompat's per-app language preferences API with support for "Follow System" mode:
@Stable
class AppLocaleManager {
fun changeLanguage(locale: AppLocaleState.AppLocale) {
val target = when (locale) {
AppLocaleState.AppLocale.FollowSystem ->
// Set empty locale list to follow system
LocaleListCompat.getEmptyLocaleList()
is AppLocaleState.AppLocale.Language ->
LocaleListCompat.create(locale.locale)
}
AppCompatDelegate.setApplicationLocales(target)
}
}
Key benefits:
- ✅ Works on Android 8.1+ (API 27)
- ✅ Persists preference across app restarts
- ✅ Integrates with Android 13+ system language settings
- ✅ No app restart required
- ✅ Seamlessly follows system locale when user prefers
Utility function to reactively observe locale changes in Compose:
@Composable
@ReadOnlyComposable
fun currentLocale(): Locale =
ConfigurationCompat.getLocales(LocalConfiguration.current)[0]
?: LocaleListCompat.getAdjustedDefault()[0]!!
One of the coolest features is the intelligent DateTimeFormatterCache
that provides:
- Thread-safe caching of immutable DateTimeFormatter instances
- ICU skeleton support for locale-aware patterns (e.g., "yMd", "jm", "yMMMdjm")
- Automatic 12h/24h normalization based on user preference
- Localized styles (SHORT, MEDIUM, LONG, FULL)
- Per-locale cache management for optimal memory usage
val formatter = DateTimeFormatterCache.getFormatterFromSkeleton(
locale = locale,
skeleton = "yMMMddHmss" // Year, abbreviated month, day, hours, minutes, seconds
)
val formattedTime = formatter.formatInstant(Instant.now(), ZoneId.systemDefault())
// Example outputs:
// English: "Jan 15, 2024, 2:30:45 PM"
// Vietnamese: "15 thg 1, 2024, 14:30:45"
Why ICU skeletons?
- 🌍 Automatically adapt to locale conventions
- 🎯 More flexible than rigid patterns
- 🔒 Safer than
DateTimeFormatter.ofPattern()
for user-facing text - ⚡ Cached for optimal performance
// Clear cache when locale changes (optional, for memory management)
DateTimeFormatterCache.clear()
// Remove formatters for specific locale
DateTimeFormatterCache.removeLocale(locale)
// Localized date formatter
val dateFormatter = DateTimeFormatterCache.getLocalizedDateFormatter(
locale = locale,
dateStyle = FormatStyle.MEDIUM
)
// Localized time formatter
val timeFormatter = DateTimeFormatterCache.getLocalizedTimeFormatter(
locale = locale,
timeStyle = FormatStyle.SHORT
)
// Localized date-time formatter
val dateTimeFormatter = DateTimeFormatterCache.getLocalizedDateTimeFormatter(
locale = locale,
dateStyle = FormatStyle.MEDIUM,
timeStyle = FormatStyle.SHORT
)
Convenient extension functions for working with Instant
:
// Format an Instant with a specific zone
val formatted = formatter.formatInstant(instant, zoneId)
// Convert Instant to ZonedDateTime
val zonedDateTime = instant.toZonedDateTime(ZoneId.systemDefault())
The build configuration defines supported locales:
object Locales {
val localeFilters = listOf(
"en",
"vi-rVN",
)
val supportedLocales: String =
localeFilters.joinToString(
separator = ",",
prefix = "\"",
postfix = "\""
) {
it.replace(
oldValue = "-r",
newValue = "-"
)
}
}
These are automatically exposed via BuildConfig.SUPPORTED_LOCALES
(comma-separated string: "en,vi-VN"
).
The DateTimeFormatterCache
implementation demonstrates enterprise-grade patterns:
- Thread-Safety: Uses
ConcurrentHashMap
for safe concurrent access - Immutability: DateTimeFormatter instances are immutable and thread-safe
- Smart Key Generation: Combines locale + descriptor + flags for precise caching
- Memory Management: Per-locale removal for granular cache control
- ICU Skeleton Normalization: Automatically handles 12h/24h preferences
Performance benefits:
- Avoids repeated expensive pattern compilation
- Reduces garbage collection pressure
- Ideal for apps with frequent datetime formatting
When to clear the cache:
// Optional: Clear when locale changes
AppCompatDelegate.setApplicationLocales(newLocaleList)
DateTimeFormatterCache.clear() // Free up memory if needed
The app provides a "Follow System" option that:
- Sets empty locale list:
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
- Automatically adopts system locale changes
- Integrates seamlessly with Android 13+ per-app language settings
Supported locales are automatically exposed via BuildConfig:
object Locales {
val localeFilters = listOf("en", "vi-rVN")
val supportedLocales: String = localeFilters.joinToString(",", "\"", "\"") {
it.replace("-r", "-")
}
}
// Available at runtime as: BuildConfig.SUPPORTED_LOCALES = "en,vi-VN"
The AppLocaleManager
parses this string to dynamically generate language options without hardcoding.
The app includes a practical demonstration of sending locale-aware HTTP requests with the Accept-Language header:
Key Components:
- AcceptedLanguageInterceptor - OkHttp interceptor that automatically adds Accept-Language header:
internal class AcceptedLanguageInterceptor(
private val localeProvider: LocaleProvider,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val locales = localeProvider.provide()
val request = chain.request()
.newBuilder()
.addHeader("Accept-Language", locales.toLanguageTags())
.build()
return chain.proceed(request)
}
}
- NetworkServiceLocator - Configures OkHttp with the interceptor:
object NetworkServiceLocator {
private val localeProvider: AcceptedLanguageInterceptor.LocaleProvider
get() = AcceptedLanguageInterceptor.LocaleProvider {
LocaleManagerCompat.getApplicationLocales(application)
.takeIf { it.size() > 0 }
?: LocaleManagerCompat.getSystemLocales(application)
}
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(AcceptedLanguageInterceptor(localeProvider))
.build()
}
}
-
DemoAcceptLanguageHeader - Composable UI that calls httpbin.org/get:
- Press "GET" to make a request to httpbin.org
- The server echoes back the Accept-Language header
- Shows how different locales result in different Accept-Language values
- Example: English →
"en"
, Vietnamese →"vi-VN"
-
MyApplication - Initializes the network service locator:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
NetworkServiceLocator.init(this)
}
}
Why this matters:
- Demonstrates real-world usage of locale information in API calls
- Shows proper architecture for locale-aware networking
- Useful pattern for apps that need server-side localization
- The Accept-Language header helps servers return content in the user's preferred language
Adding a new language is straightforward:
1. Update build configuration (app/build.gradle.kts
):
object Locales {
val localeFilters = listOf(
"en",
"vi-rVN",
"fr-rFR", // ← Add new locale
)
// ...
}
2. Create resource directory app/src/main/res/values-{lang}/
3. Add strings.xml
with translated strings:
<resources>
<string name="app_name">Votre Nom d\'App</string>
<string name="current_locale_language_country">Locale actuelle: %1$s, langue: %2$s, pays: %3$s, languageTag: %4$s</string>
<string name="follow_system">Suivre le système</string>
<string name="demo_datetime_formatter">Maintenant c\'est %1s</string>
</resources>
4. Rebuild → Language appears automatically in the app! ✨
The app includes a live demonstration of locale-aware datetime formatting:
@Composable
private fun DemoDateTimeFormatter(
locale: Locale,
modifier: Modifier = Modifier,
clock: Clock = Clock.systemDefaultZone(),
) {
val now: Instant = remember(clock) { Instant.now(clock) }
val timeFormatter = DateTimeFormatterCache.getFormatterFromSkeleton(
locale = locale,
skeleton = "yMMMddHmss"
)
Text(
text = stringResource(
R.string.demo_datetime_formatter,
timeFormatter.formatInstant(now, clock.zone),
),
style = MaterialTheme.typography.bodyLarge,
)
}
This demonstrates how date/time formatting automatically adapts to the selected locale without any manual formatting logic.
Modern edge-to-edge display with proper window insets handling:
enableEdgeToEdge()
Implements Material You design (dynamic color disabled for consistency):
JetpackComposeLocalizationTheme(dynamicColor = false) {
// Content
}
The app logs lifecycle events for debugging:
lifecycle.eventFlow
.onEach { Log.d("MainActivity", ">>> lifecycle event: $it") }
.launchIn(lifecycleScope)
# Unit tests
./gradlew test
# Instrumentation tests
./gradlew connectedAndroidTest
./gradlew assembleRelease
# APK output: app/build/outputs/apk/release/
Contributions are welcome! Please:
- Open an issue first for major changes
- Follow Kotlin conventions
- Add tests for new features
- Update documentation
- Ensure tests pass before submitting PR
Available for educational and demonstration purposes. See repository for license details.
- Built with Jetpack Compose
- AndroidX AppCompat for per-app language preferences
- Material Design 3 guidelines
- ICU for locale-aware patterns
- Android Localization Guide
- Per-app language preferences
- Jetpack Compose Documentation
- Material Design 3
hoc081098
- GitHub: @hoc081098
If you find this project helpful, please consider giving it a ⭐️ on GitHub!
For issues, questions, or suggestions, please open an issue on the GitHub repository.