A Kotlin SDK for over-the-air (OTA) localization with LingoHub. Update your app's translations without releasing a new app version.
Contents: How it works · Installation · Get your API key · Quick Start · Configuration · Advanced Usage · Error handling · Privacy · Sample app
- 🚀 Over-the-air localization updates via the LingoHub CDN
- ⏱ Paces its own update checks and backs off when the CDN is unavailable
- 🔄 Runtime language switching
- 📱 Works with XML resources and Jetpack Compose
- 🛠 Seamless integration — keep using
getString(...)andstringResource(...)as usual - 🔔 Also covers Services, push notifications, and the application context
- 📦 Supports string resources, plurals, and string arrays
- 🔒 Descriptive error reporting
- 📝 Optional debug logging
- Publish a release for a Distribution in LingoHub.
- The SDK asks the LingoHub CDN whether a release matching your app version is available. Releases can target app version ranges, with an optional fallback release for all other versions.
- If there is a new release, the SDK downloads it and serves the updated strings through the standard Android resource APIs.
- Downloaded translations are cached on disk and discarded automatically when your app version changes, so a fresh app release always starts from its bundled strings.
If nothing has been published yet for your app version and environment, the SDK simply reports that no update is available — that is a normal state, not an error.
- Android API level 24+
- AndroidX
- Your app must compile against
compileSdk36 or later
The SDK is available on Maven Central:
// build.gradle.kts
dependencies {
implementation("com.lingohub:android-cdn-sdk:1.2.0")
}Groovy DSL
// build.gradle
dependencies {
implementation 'com.lingohub:android-cdn-sdk:1.2.0'
}mavenCentral() is part of the default repository setup of every current Android project, so no extra repository configuration is needed.
Migrating from JitPack? Earlier versions were distributed via JitPack under
com.github.lingohubcoordinates. JitPack is no longer supported — switch to the Maven Central coordinates above and remove themaven { url 'https://jitpack.io' }repository if nothing else uses it.The public API was also renamed to the LingoHub brand spelling in 1.1.0. Update your imports and references:
Old (JitPack era) New LingohubLingoHubLingohubUpdateListenerLingoHubUpdateListenerLingohubSDKErrorLingoHubSDKErrorLingohubLogLevelLingoHubLogLevelPackage names are unchanged (
com.lingohub.android.cdn.*), so this is a find-and-replace of the type names.
- In LingoHub, open your project and create a Distribution (type: Mobile SDK Android).
- Publish a release for the environment you want to use (or mark one release as the fallback).
- Copy the distribution's CDN API key — it starts with
lh-cdn_.
See the LingoHub CDN documentation for details.
import android.app.Application
import com.lingohub.android.cdn.core.LingoHub
class YourApplication : Application() {
override fun onCreate() {
super.onCreate()
LingoHub.configure(
context = this,
apiKey = "lh-cdn_..."
)
// Fetch the latest translations
LingoHub.update()
}
}LingoHub replaces strings by wrapping the Activity context, so every Activity — XML and Compose based — needs the LingoHub AppCompatDelegate. The easiest way is a BaseActivity that all your Activities extend:
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import com.lingohub.android.cdn.core.LingoHub
abstract class BaseActivity : AppCompatActivity() {
private val lingoHubDelegate: AppCompatDelegate by lazy {
LingoHub.getAppCompatDelegate(this, AppCompatDelegate.create(this, null))
}
override fun getDelegate(): AppCompatDelegate {
return lingoHubDelegate
}
}class MainActivity : BaseActivity() {
// Your activity code
}The delegate is mandatory: it is what routes resource lookups through LingoHub. Activities without it keep showing the strings packaged in your APK — downloaded translations are never applied to them, not even after an app restart.
Strings you read outside Activities — in a Service, a BroadcastReceiver, a push notification, or through applicationContext — need a small addition, see Strings outside Activities.
// XML / programmatic
context.getString(R.string.your_string)
// Jetpack Compose
Text(text = stringResource(R.string.your_string))Plurals (getQuantityString) and string arrays (getStringArray) work the same way.
import com.lingohub.android.cdn.core.LingoHub
import com.lingohub.android.cdn.data.model.Environment
import com.lingohub.android.cdn.utils.LingoHubLogLevel
LingoHub.configure(
context = this,
apiKey = "lh-cdn_...",
environment = Environment.PRODUCTION, // optional, defaults to PRODUCTION
logLevel = LingoHubLogLevel.FULL // optional, defaults to NONE
)| Parameter | Values | Default |
|---|---|---|
environment |
Environment.PRODUCTION, Environment.STAGING, Environment.DEVELOPMENT, Environment.TEST |
Environment.PRODUCTION |
logLevel |
LingoHubLogLevel.NONE, LingoHubLogLevel.FULL |
LingoHubLogLevel.NONE |
The environment must match the environment of the release you published. Enable FULL logging only in debug builds:
logLevel = if (BuildConfig.DEBUG) LingoHubLogLevel.FULL else LingoHubLogLevel.NONEimport java.util.Locale
LingoHub.setLocale(Locale.GERMAN)Already-rendered screens don't re-render themselves: recreate the Activity, or drive your Compose UI from a locale state as shown in the sample app. LingoHub.getCurrentLocale() returns the active locale — use it to initialize that state so it survives Activity recreation.
Implement LingoHubUpdateListener to react when a new translation bundle has been downloaded. Recreating the Activity is the simplest way to apply updates immediately — make sure your Activity state survives recreation:
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import com.lingohub.android.cdn.core.LingoHub
import com.lingohub.android.cdn.core.LingoHubUpdateListener
abstract class BaseActivity : AppCompatActivity(), LingoHubUpdateListener {
private val lingoHubDelegate: AppCompatDelegate by lazy {
LingoHub.getAppCompatDelegate(this, AppCompatDelegate.create(this, null))
}
override fun getDelegate(): AppCompatDelegate {
return lingoHubDelegate
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
LingoHub.addUpdateListener(this)
}
override fun onDestroy() {
super.onDestroy()
LingoHub.removeUpdateListener(this)
}
override fun onUpdate() {
// Recreate the activity to reload all resources with the new translations.
// If you skip this, the new strings are applied on the next app start.
runOnUiThread {
recreate()
}
}
override fun onFailure(throwable: Throwable) {
// See "Error handling" below
}
}CDN usage counts towards your plan, so the SDK paces its checks itself: after a successful update check, LingoHub.update() returns without contacting the CDN for the next 15 minutes. Call it whenever your app starts or comes to the foreground, as in the Quick Start, and leave the pacing to the SDK. Debuggable builds check on every call, so you see new releases right away while you develop.
To check less often, raise the interval:
import java.util.concurrent.TimeUnit
LingoHub.setMinimumCheckInterval(1, TimeUnit.DAYS) // at most once a dayA failed check doesn't start the interval: the next update() call tries again, unless the SDK paused checks after server errors or a 429 (see Failures and retries).
The Activity delegate covers Activities. Everything else reads strings through a context of its own: the Application — and with it applicationContext and WorkManager workers — every Service, such as the FirebaseMessagingService that builds your push notifications, and every BroadcastReceiver. Route the Application and your Services through LingoHub by returning LingoHub.wrap(...) resources from getResources():
import android.app.Application
import android.content.res.Resources
import com.lingohub.android.cdn.core.LingoHub
class YourApplication : Application() {
private val lingoHubContext by lazy { LingoHub.wrap(baseContext) }
override fun getResources(): Resources = lingoHubContext.resources
override fun onCreate() {
super.onCreate()
LingoHub.configure(context = this, apiKey = "lh-cdn_...")
}
}A Service has a base context of its own and needs the same two lines:
import android.content.res.Resources
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.lingohub.android.cdn.core.LingoHub
class PushService : FirebaseMessagingService() {
private val lingoHubContext by lazy { LingoHub.wrap(baseContext) }
override fun getResources(): Resources = lingoHubContext.resources
override fun onMessageReceived(message: RemoteMessage) {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentText(getString(R.string.new_message_body)) // downloaded translation
.build()
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
}
}A BroadcastReceiver reads strings through the context Android passes to onReceive(). For most receivers declared in your manifest, that context delegates to your Application, but not for all of them: a receiver with android:attributionTags, for example, gets a context of its own. Wrap it where you read strings:
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.lingohub.android.cdn.core.LingoHub
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val text = LingoHub.wrap(context).getString(R.string.reminder_text) // downloaded translation
// ...
}
}- Override
getResources()— don't passLingoHub.wrap(base)tosuper.attachBaseContext(...). Android requires the application's base context to be its own implementation when it delivers broadcasts to receivers declared in the manifest (Firebase Messaging and WorkManager declare some), so wrapping it crashes the app with aClassCastException. - Wrapped contexts are safe to use before
LingoHub.configure()runs — they return the strings packaged in your APK until a release is available — and from any thread. They followLingoHub.setLocale(...), pick up a new release with the next lookup, and follow configuration changes such as a new system language or dark mode, including theapplication.resourcesa long-lived helper keeps. - For any other context, wrap it where you read the string:
LingoHub.wrap(context).getString(R.string.your_string). This includes contexts created withcreateConfigurationContext(...), for example byContextCompat.getContextForLanguage(...). Keep a wrapped context you use repeatedly instead of wrapping it on every call. - Downloaded strings are always in LingoHub's language: the one you pass to
LingoHub.setLocale(...), otherwise the app's current default locale (Locale.getDefault()). A context created for another language, withcreateConfigurationContext(...)orContextCompat.getContextForLanguage(...), changes only which packaged strings serve as the fallback.
wrap() covers the string lookups your code makes. These stay as packaged in your APK:
- Layouts inflated outside Activities, such as the overlay window of a
Service:android:textin the layout XML is not replaced. Set these texts in code. - Home-screen widgets and custom notification layouts (
RemoteViews): the launcher or System UI inflates them in its own process and resolves their resource ids from your APK, where no SDK can intercept them. Pass texts resolved in your app instead, e.g.views.setTextViewText(R.id.title, context.getString(R.string.widget_title)). Resources.getSystem(), which holds only Android's own resources and is never intercepted.
Two situations are not errors and never reach your listener — the SDK just logs them at info level:
- Already up to date — the CDN answered that you have the latest release.
- Nothing published yet — no release exists for your environment and app version (
DISTRIBUTION_NOT_FOUND). Publish a release in your Distribution to resolve this.
Real failures are delivered to LingoHubUpdateListener.onFailure(throwable) as a LingoHubSDKError carrying the HTTP status and the server's error codes as structured fields — statusCode: Int? and errorCodes: List<String> — so you can react without parsing the message:
| Status | Error codes | Meaning and what to do |
|---|---|---|
| 401 | CDN_KEY_NOT_FOUND, CDN_KEY_EXPIRED, TOKEN_EXPIRED, JWT_INVALID |
The API key is missing, invalid, revoked, or rotated — check the key you pass to configure |
| 429 | USAGE_LIMIT_EXCEEDED |
Your CDN usage budget is exhausted; the SDK pauses its checks for an hour (see Failures and retries) |
| 5xx | — | The CDN is temporarily unavailable; the SDK retries once, then pauses its checks (see Failures and retries) |
| 400 | — | Malformed request — usually an SDK/backend version mismatch, please report it |
| other | — | Network errors and unexpected responses |
override fun onFailure(throwable: Throwable) {
val error = throwable as? LingoHubSDKError
when {
error?.statusCode == 429 -> Unit // Usage budget exhausted: the SDK pauses its checks on its own
error != null && "CDN_KEY_EXPIRED" in error.errorCodes -> alertKeyRotationNeeded()
else -> Log.w("MyApp", "LingoHub update failed: ${throwable.message}")
}
}statusCode is null for local and network errors (no response was received).
The SDK paces update checks and handles failed ones on its own, with the same policy on iOS and Android:
| Failure | Behavior |
|---|---|
| Transport error (offline, DNS, timeout) | No automatic retry. The next update() call, for example on the next app foreground, is the retry. |
5xx from the CDN |
One retry after the delay the response's Retry-After asks for, or after a random 2–5 seconds when it has none; a Retry-After over 10 seconds skips the retry. If the check still gets a 5xx, update checks pause for 5 minutes, doubling with each further failed update in a row up to 1 hour, and never shorter than Retry-After. |
429 (USAGE_LIMIT_EXCEEDED) |
Update checks pause for 1 hour, or for as long as Retry-After asks when that is longer. |
Other 4xx (400, 401, a 404 without DISTRIBUTION_NOT_FOUND) |
No retry. Logged once per app process and reported to your app. |
404 with DISTRIBUTION_NOT_FOUND |
Not an error: nothing is published for your app version yet. The normal minimum interval applies. |
Download failure (expired download URL, 5xx from storage) |
One fresh check to get a new download URL, then no further attempt until the next update() call. |
| Minimum interval between checks | After a successful check, update() doesn't contact the CDN for 15 minutes (no interval in debug builds). Configurable, see Reduce network requests. |
Pauses and the minimum interval are stored on the device and survive app restarts. A new app version, another environment, or another CDN key starts without them. No pause lasts longer than 24 hours, whatever Retry-After asks for. An update check that is running when your app calls configure() again stops at its next step without changes: it sends no further request, installs nothing, records nothing and reports nothing. The next update() call checks with the new configuration. "Logged once" refers to the SDK's error message: with full logging enabled, the request trace still lists every request.
While update checks are paused, LingoHub.update() reports the failure that caused the pause to onFailure, with its status code and error codes, without contacting the CDN. Within the minimum interval, it returns without calling your listener.
LingoHub.update()makes no network request — the last successful check was less than the minimum interval ago (15 minutes; none in debuggable builds). See Reduce network requests.onUpdatenever fires and nothing changes — most likely no release is published yet for your app version and environment. Publish a release in your Distribution (or mark one as the fallback), and double-check that theenvironmentyou configure matches the release's environment. EnableLingoHubLogLevel.FULLin a debug build to see what the SDK is doing.- Strings never change, not even after an app restart — your Activities don't use the LingoHub delegate. It is mandatory; see Quick Start step 2.
- Strings change only after leaving and reopening a screen — the delegate is in place, but you don't
recreate()the visible Activity inonUpdate. - Notifications, Services, receivers, or
applicationContextshow the packaged strings — the Activity delegate doesn't reach them; see Strings outside Activities. - Error 401 — the CDN key is missing, invalid, or was revoked. The error message contains the reason (for example
CDN_KEY_NOT_FOUND). - Error 429 — your CDN usage budget is exhausted. The SDK pauses its checks for an hour, or longer when the CDN asks for it, and reports the 429 without contacting the CDN until then.
No configuration needed — the SDK ships its consumer rules inside the AAR.
What the SDK touches on the device and network — relevant for your Play Console Data safety declaration:
SharedPreferences— stores the installed release ID, app version, the SDK's client identifier, and the update check schedule: when the last check succeeded and, after server errors or a 429 response, until when checks are paused.- Downloaded translation bundles — stored in the app's internal files directory.
- Each update check sends to the LingoHub CDN: your app's version name, the current app language, the SDK version, and a random per-install identifier (a UUID generated on first launch and kept in the SDK's private
SharedPreferences) as the client identifier for usage metering. The identifier is not derived from any hardware or device ID, cannot be correlated across apps, and resets when the app is uninstalled or its data is cleared. In your Play Console Data safety form it falls under Device or other IDs (the same category as app-generated identifiers like a Firebase installation ID). No hardware identifier such asANDROID_IDis read or transmitted.
The sample module in this repository shows a complete Compose integration, including runtime language switching and update notifications. Open the project in Android Studio, insert your CDN API key in LingoHubApplication.kt, and run the sample configuration.
For bug reports and feature requests, please open an issue on GitHub.
Apache License Version 2.0, January 2004. More info in the LICENSE file.