Skip to content

About

A Kotlin SDK for over-the-air (OTA) localization with LingoHub — update your app's translations without releasing a new app version.

Resources

Stars

2 stars

Watchers

2 watching

Forks

Repository files navigation

LingoHub Android SDK

Maven Central License Platform

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

Features

  • 🚀 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(...) and stringResource(...) as usual
  • 🔔 Also covers Services, push notifications, and the application context
  • 📦 Supports string resources, plurals, and string arrays
  • 🔒 Descriptive error reporting
  • 📝 Optional debug logging

How it works

  1. Publish a release for a Distribution in LingoHub.
  2. 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.
  3. If there is a new release, the SDK downloads it and serves the updated strings through the standard Android resource APIs.
  4. 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.

Requirements

  • Android API level 24+
  • AndroidX
  • Your app must compile against compileSdk 36 or later

Installation

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.lingohub coordinates. JitPack is no longer supported — switch to the Maven Central coordinates above and remove the maven { 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
Lingohub LingoHub
LingohubUpdateListener LingoHubUpdateListener
LingohubSDKError LingoHubSDKError
LingohubLogLevel LingoHubLogLevel

Package names are unchanged (com.lingohub.android.cdn.*), so this is a find-and-replace of the type names.

Get your API key

  1. In LingoHub, open your project and create a Distribution (type: Mobile SDK Android).
  2. Publish a release for the environment you want to use (or mark one release as the fallback).
  3. Copy the distribution's CDN API key — it starts with lh-cdn_.

See the LingoHub CDN documentation for details.

Quick Start

1. Configure the SDK in your Application class

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

2. Wrap your Activities

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.

3. Use your strings as usual

// 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.

Configuration

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.NONE

Advanced Usage

Switch languages at runtime

import 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.

Update notifications

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

Reduce network requests

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 day

A 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).

Strings outside Activities

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 pass LingoHub.wrap(base) to super.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 a ClassCastException.
  • 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 follow LingoHub.setLocale(...), pick up a new release with the next lookup, and follow configuration changes such as a new system language or dark mode, including the application.resources a 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 with createConfigurationContext(...), for example by ContextCompat.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, with createConfigurationContext(...) or ContextCompat.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:text in 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.

Error handling

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

Failures and retries

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.

Troubleshooting

  • 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.
  • onUpdate never 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 the environment you configure matches the release's environment. Enable LingoHubLogLevel.FULL in 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 in onUpdate.
  • Notifications, Services, receivers, or applicationContext show 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.

R8 / ProGuard

No configuration needed — the SDK ships its consumer rules inside the AAR.

Privacy

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 as ANDROID_ID is read or transmitted.

Sample app

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.

Support

For bug reports and feature requests, please open an issue on GitHub.

License

Apache License Version 2.0, January 2004. More info in the LICENSE file.

About

A Kotlin SDK for over-the-air (OTA) localization with LingoHub — update your app's translations without releasing a new app version.

Resources

Stars

2 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages