Docs / Android SDKSDK downloadsGitHub ↗

Android SDK

Beta · 0.1.0-beta.7 · Android API 23+

The Kotlin-first SDK owns installation bootstrap, FCM token refresh, encrypted credentials, bounded offline event/receipt retry, consent, identity, and signed notification receipts. Java callers can use its public static entry points.

Prerequisites

  1. Create an Android app in PushHub and connect its delivery provider on the App setup page: either connect your own Firebase project (service-account JSON plus google-services.json) or, once PushHub-managed Android is enabled for your company, let PushHub provision it. Managed provisioning places each company in one isolated PushHub-operated Firebase project with a separate Android app registration per PushHub app.
  2. Download the versioned PushHub configuration bundle and add its google-services.json and pushhub.json to the Android application.
  3. In App → Integration → Client SDK access, create a publishable ph_pub_ key. Choose its environment: use a Development key in debug builds and a Production key in release builds. Devices take the environment of the key they register with.
  4. Never place a server ph_live_ key in an APK or source repository.

Install

The SDK is published to PushHub's Maven repository. Its POM declares every dependency, so Gradle adds Firebase Messaging, AndroidX and Kotlin coroutines for you. Add the repository once, in settings.gradle.kts:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://docs.pushhub.kemegames.com/maven") }
    }
}

Then add the dependency to the app module:

dependencies {
    implementation("com.kemegames.pushhub:pushhub-android:0.1.0-beta.7")
}

If your project declares repositories in the root build.gradle (allprojects { repositories { ... } }) instead, add the maven { ... } line there. Every file in the repository has .sha1, .sha256 and .sha512 checksums next to it, and versions are never changed once published. Releases up to 0.1.0-beta.6 are available only as AAR downloads.

Direct download. The same AAR is on the downloads page. An .aar file carries no dependencies, so declare them yourself: Firebase Messaging 24.1.0, AndroidX Core KTX 1.15.0, Security Crypto 1.1.0-alpha06, WorkManager 2.10.0, Kotlin Coroutines Android 1.9.0 and the Kotlin standard library 2.1.0.

Requirements. compileSdk 35 or later (AndroidX Core 1.15 needs it), minSdk 23 or later, and Java 17.

What each release added. Beta 6 added instant refresh of remote config and signed config verification. Beta 5 added device context reporting, the remote config API (PushHub.config) and the config methods the Unity package calls. Beta 4 fixed token-refresh synchronization, sends the device's delivery transport at registration, and added the provider adapter contract for Huawei Push Kit and Amazon Device Messaging. Beta 3 introduced verified bundle-first configuration. Every release accepts Beta 3 and later configuration bundles. Native apps use the same core that powers the engine adapters.

Huawei and Amazon devices

Firebase is the default transport. On Huawei devices without Google Play services or on Amazon Fire devices, connect Huawei Push Kit or Amazon Device Messaging on the App setup page, then pass a PushHubProviderAdapter built on the vendor client library your app already ships. PushHub does not bundle vendor libraries.

class AmazonAdapter(private val registrationId: suspend () -> String) : PushHubProviderAdapter {
    override val name = "ADM"          // "FCM", "HMS", or "ADM"
    override suspend fun currentToken() = registrationId()
}

PushHub.initialize(applicationContext, provider = if (isFireOs) AmazonAdapter { admRegistrationId() } else null)

In the vendor's messaging service, forward new tokens with PushHub.scheduleTokenRefresh(context, token) and messages with PushHub.handleDataMessage(context, data). handleDataMessage records the delivery receipt and, for ADM data messages carrying pushhub_title and pushhub_body, posts the notification on the pushhub channel. See docs/PROVIDER_CONNECTIONS.md for connection setup.

Configure and start

PushHub.configure(
    applicationContext,
    PushHubConfig(
        appId = UUID.fromString(BuildConfig.PUSHHUB_APP_ID),
        publishableKey = BuildConfig.PUSHHUB_PUBLISHABLE_KEY
    )
)

lifecycleScope.launch { PushHub.start() }

Configuration does not prompt for notification permission. Calling configure again with the same app ID, publishable key and endpoint does nothing; different values throw IllegalStateException. start() gets the current FCM token, creates or recovers the installation, stores its credential with Android Keystore-backed encrypted preferences, and flushes eligible queued writes.

From 0.1.0-beta.7, configure (and initialize) also store these values on the device, in plain preferences: the publishable key is public and ships in the app anyway. When Android starts the app's process just to deliver a push or run a retry, no app code has run, so PushHub configures itself from pushhub.json or, failing that, from the values the app last passed to configure. If the app then configures in that same process, equal values change nothing and different ones (for example a new key after an update) replace the stored ones.

Device context

Requires SDK 0.1.0-beta.5 or later. Earlier versions do not report device context, so segments fall back to a metadata key of the same name.

start() also reports the device context that segments target as built-in fields, with no code on your side:

Field Source
appVersion PackageInfo.versionName, e.g. 2.4.1
appBuild PackageInfo.longVersionCode, e.g. 241
osVersion Build.VERSION.RELEASE, e.g. 14
locale Locale.getDefault().toLanguageTag(), e.g. ar-SA (the server derives language, e.g. ar)
country Locale.getDefault().country, e.g. SA
timezone TimeZone.getDefault().id, e.g. Asia/Riyadh

The context is sent when the installation is created and again from the next start() after it changes, for example after an app update or a language change. Values the server would reject, such as a numeric region like 419 or a custom GMT+03:00 zone, are left out rather than failing the registration.

Permission and channels

Ask only from UI where the player understands the value:

PushHub.createNotificationChannel(this, id = "live_ops", name = "Live updates")
if (!PushHub.hasNotificationPermission(this)) PushHub.requestNotificationPermission(this)

Android 12 and earlier do not use the runtime POST_NOTIFICATIONS prompt. The SDK never displays it automatically.

lifecycleScope.launch {
    PushHub.identify("player-1842", mapOf("tier" to "gold", "region" to "mena"))
    PushHub.setConsent(PushHubConsent.GRANTED)
    PushHub.track("level_completed", mapOf("level" to "20"))
}

lifecycleScope.launch { PushHub.unlinkUser() } // on logout

identify attributes merge into the traits already stored for the device: keys you pass are added or overwritten, a null value removes that key, and keys you leave out are kept. Calling identify(userId) without attributes no longer clears earlier traits. The server does the merging, so it applies to every SDK version.

PushHub.identify("player-1842", mapOf("tier" to "platinum", "region" to null)) // update tier, drop region

They are separate, and neither implies the other:

  • Notification permission (Android 13+ POST_NOTIFICATIONS, or notifications turned off in system settings) only decides whether notifications can be shown. A device without it stays registered, remote config fetches work, and silent config refreshes are still handled, because they are never shown.
  • Data-processing consent is your app's decision, passed with PushHub.setConsent. With DENIED, the server deactivates the device. From 0.1.0-beta.7 the SDK also sends nothing for remote config while the stored consent is DENIED: a fetch fails with IllegalStateException without making a request, and a silent refresh is only remembered. The stored consent changes when the server accepts setConsent. After GRANTED, the next fetch picks up any refresh missed in the meantime, even inside the minimum fetch interval.

Events and receipts retry only transport, timeout, rate-limit, and server failures. The encrypted queue retains at most 100 records for seven days and WorkManager waits for connectivity. Authentication and validation failures are removed instead of looping.

FCM lifecycle

The AAR manifest registers PushHubFirebaseMessagingService. If the host needs raw callbacks, subclass it:

class GameMessagingService : PushHubFirebaseMessagingService() {
    override fun onPushHubMessage(message: RemoteMessage) {
        // Render or route according to the host application's policy.
    }
}

The base service forwards refreshed FCM tokens and reports receipt when Android delivers a data message to the process. In a process Android started for the message, it first configures PushHub from pushhub.json or the stored configuration (see Configure and start). PushHub's silent config refreshes are handled inside the SDK instead (see Instant refresh). Call PushHub.reportOpened(payload) from the notification-open path. Receipt tokens—not notification UUIDs alone—authorize reports.

Remote config

Requires SDK 0.1.0-beta.5 or later.

Remote config lets you change values in a shipped game, such as starter coins, a maintenance flag or a store layout, from the PushHub dashboard. You reach it through PushHub.config (PushHub.getConfig() from Java).

Set defaults

Set in-app defaults early, for example in Application.onCreate. The getters use them before the first fetch and for any key the server leaves out.

PushHub.config.setDefaults(mapOf(
    "starter_coins" to 100,
    "maintenance_mode" to false,
    "welcome_text" to "Welcome!",
    "store_layout" to mapOf("tabs" to listOf("offers", "coins"))
))
// Or from a bundled JSON object:
PushHub.config.setDefaults(assets.open("remote_config_defaults.json").bufferedReader().readText())

setDefaults replaces the previous defaults, and a null value is ignored.

Fetch on load

lifecycleScope.launch {
    PushHub.start()
    val changed = PushHub.config.fetchAndActivate()   // true when the active values changed
    applyConfig()
}

From Java, use the callback variant, which runs on the main thread:

PushHub.getConfig().fetchAndActivate(false, (changed, error) -> {
    if (error == null && changed) applyConfig();
});

Fetching needs a registered installation, so call it after start(). Before fetching, the SDK reports a changed device context (for example a new app version or locale), so targeting conditions use current values.

To apply config only at a safe moment, such as a loading screen, fetch and activate separately:

PushHub.config.fetch()          // downloads, but nothing changes yet
// ...later, on the loading screen
if (PushHub.config.activate()) applyConfig()

Read values

val coins = PushHub.config.getLong("starter_coins")
val maintenance = PushHub.config.getBoolean("maintenance_mode")
val multiplier = PushHub.config.getDouble("price_multiplier")
val text = PushHub.config.getString("welcome_text")
val layoutJson: String? = PushHub.config.getJson("store_layout")   // raw JSON text, or null
val source = PushHub.config.getValue("starter_coins").source         // REMOTE, DEFAULT or STATIC
val everything = PushHub.config.getAll()

Each getter looks for the active remote value first, then your in-app default, and otherwise returns the type's zero value ("", false, 0, 0.0, or null for JSON). The getters never throw. A decimal read with getLong is truncated toward zero, and a numeric string such as "42" converts to a number.

Listen for updates

val listener = PushHubRemoteConfigListener { changedKeys ->
    if ("maintenance_mode" in changedKeys) refreshMaintenanceBanner()
}
PushHub.config.addOnUpdatedListener(listener)
PushHub.config.removeOnUpdatedListener(listener)

Listeners run on the main thread after an activation that changed values. They receive only the keys that were added, removed or changed.

Offline and cold start

The active values, the ETag, the version and the last fetch time are stored on the device. When PushHub is configured, they load synchronously, before any network call, so the last activated config is available offline and on the next cold start. With trusted signing keys, the stored config is verified again as it loads.

A failed fetch never throws. fetchAndActivate() and fetch() return false, the callback receives the error, and the active values stay as they were. Each fetch sends the stored ETag; when nothing changed, the server answers 304 Not Modified and no data is downloaded.

Fetch interval and timeout

  • Fetch interval: a fetch reaches the network at most once per interval. The interval is the larger of the server's minimumFetchIntervalSeconds (900 seconds by default) and your PushHub.config.minimumFetchIntervalSeconds (0 by default). A call inside the interval succeeds and uses the stored config.
  • When the interval is skipped (0.1.0-beta.7 and later): while a refresh has announced a version the device doesn't have yet, and once after the SDK dropped a config that failed its signature check.
  • Debug builds: pass force = true to skip the interval.
  • Timeout: PushHub.config.fetchTimeoutSeconds sets the connect and read timeout, 10 seconds by default.
PushHub.config.minimumFetchIntervalSeconds = 3600       // at most hourly
PushHub.config.fetchAndActivate(force = BuildConfig.DEBUG)

Instant refresh

Requires SDK 0.1.0-beta.6 or later; no code is needed. Before 0.1.0-beta.7, an app that configures PushHub from code (rather than shipping pushhub.json) got refreshes only while its process was running.

PushHub sends refreshes only to installations that report 0.1.0-beta.6 or later. Up to 0.1.0-beta.5, the SDK passes every data message to onPushHubMessage, where an app that displays its messages would show the refresh. A device on an older release isn't sent one and picks up the new config on its next normal fetch. This applies to the engine and framework SDKs too: they report the Android core version they embed.

When you publish, roll back or schedule a publish with Notify devices set to Test devices only or All players, PushHub sends the environment's devices a silent data message. The SDK handles it for you:

  • The message is never shown, isn't reported as a delivery, and isn't passed to onPushHubMessage. Notification permission plays no part (see Consent vs notification permission).
  • When the announced version is newer than PushHub.config.activeVersion, or can't be read, the SDK calls fetchAndActivate(force = true). The new values are active as soon as the fetch completes, and your addOnUpdatedListener listeners receive the changed keys. When the device already has that version, nothing happens.
  • It works through the PushHubFirebaseMessagingService that the AAR registers, including your subclass of it. On Huawei and Amazon devices, PushHub.handleDataMessage handles it. If another FirebaseMessagingService receives your app's messages, pass PushHub's messages to PushHub.handleDataMessage(context, message.data).
  • When the app isn't running. Android starts the app's process for the message, and no app code runs before the SDK handles it. From 0.1.0-beta.7 the SDK configures itself there from pushhub.json or, for apps that configure from code, from the configuration the app last passed to PushHub.configure on that device (see Configure and start). So an app has to have run, and configured PushHub, once since updating to 0.1.0-beta.7. Your trusted signing keys apply there too (see Signed config).
  • A refresh the device couldn't act on (offline, no stored configuration yet, consent DENIED, or the process stopped mid-fetch) is remembered from 0.1.0-beta.7. Until the device has that version, the next fetch skips the minimum interval, so fetchAndActivate() on load picks it up at the next launch. The SDK forgets it once the device has that version, or after one such fetch the server answered, so a refresh never turns the interval off for good.
  • Engine wrappers. Unity, Flutter, React Native, Godot and Unreal call this core on Android, but this page doesn't describe them. What each wrapper supports in the background, and what has been validated through its own API and packaging, is in its own guide and in SDK support levels.

The new values can arrive mid-session. If part of your game must change only at a safe moment, read those values at that moment, such as the start of a level.

Instant refresh is best effort. Android can delay data messages while a device dozes, a force-stopped app receives none until it is opened again, and PushHub rate-limits refreshes to once every 10 minutes for all players (once a minute for test devices). Keep calling fetchAndActivate() on load: it picks up a refresh the device knows it missed, and otherwise reaches the server once the minimum fetch interval (900 seconds by default) has passed.

Signed config

Requires SDK 0.1.0-beta.6 or later.

When an environment has a signing key (App setup → Config signing), PushHub signs every config response with it: ECDSA P-256 with SHA-256 (ES256) over the exact response bytes, sent in the X-PushHub-Signature header. The SDK checks the signature once you give it the public keys you trust, keyed by key ID:

// In Application.onCreate, next to configure().
PushHub.config.setTrustedSigningKeys(mapOf(
    "KEY_ID" to "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."   // the public key (base64 SPKI) from App setup → Config signing
))
  • With keys set: a config is used only when a listed key signed those exact bytes. An unsigned response, an unknown key ID or a wrong signature fails the fetch with PushHubSignatureException, whose code is "signature". fetchAndActivate() returns false, the Java callback receives the exception, and the active values stay as they were. A 304 Not Modified carries no signature and keeps the values you already verified.
  • Every stored config, not just fresh fetches (0.1.0-beta.7 and later): each stored config keeps the exact response bytes and signature it arrived with, and is verified again whenever it could become or stay active:
    • activate(): a config fetched before you set the keys, or signed by a key you no longer trust, isn't activated. activate() returns false, the Java callback of fetchAndActivate receives PushHubSignatureException, and the next fetch skips the minimum interval.
    • Loading at startup: a stored config that fails the check isn't served. The getters return your in-app defaults (source DEFAULT), a warning is logged, and the next fetch skips the minimum interval.
    • Changing the keys: the active config is checked again at once. If a key you still trust signed it, nothing changes; if not, your listeners receive its keys, the getters return the in-app defaults, and the next fetch skips the interval.
  • The keys are stored on the device (0.1.0-beta.7 and later). A process Android starts for an instant refresh, before any of your code runs, enforces the last keys your app set. Calling setTrustedSigningKeys again replaces them. Setting them late is now safe, but setting them next to configure() still verifies from the first fetch.
  • Without keys (the default): nothing is checked. To stop checking, call setTrustedSigningKeys(emptyMap()). That clears the stored keys too; only removing the call from your code leaves the stored keys in force.
  • Updating from 0.1.0-beta.6 or earlier: configs those versions stored have no signed response to check. With keys set, the first launch after the update serves the in-app defaults until its first fetch, which skips the interval.
  • Bad keys: setTrustedSigningKeys throws IllegalArgumentException for a blank key ID or a key that isn't a P-256 public key, so a typo shows up in development. PEM text (-----BEGIN PUBLIC KEY-----) works too.
  • Rotating keys: list every key you trust; an app can list more than one. Plan a rotation in two steps: Prepare next key in App setup (it does not sign yet), ship an app version that trusts both the current and the prepared key, then Rotate once enough players have updated. With the prepared key listed, values never fall back to the defaults during the rotation, and a later app version can drop the retired key. An emergency rotation without preparing signs with the new key at once, and app versions that don't list it fail their fetches and keep their last values until players update.
  • Engine wrappers: this is the native Android API. Unity, Flutter, React Native, Godot and Unreal don't expose it; see their own guides.

Reset and diagnostics

lifecycleScope.launch { PushHub.reset() }
val state = PushHub.diagnostics()

Reset attempts server deactivation, then always erases local credentials and queued operations. Diagnostics exposes only configured status, public installation ID, SDK version, queue count, and consent—not secrets, tokens, identity, content, or event properties.

See compatibility.json on GitHub and the sample.