---
title: "Android SDK"
description: "In-app update checks, staged rollouts, feedback, and crash reporting for Android."
category: "SDKs & API"
order: 10
---

# Android SDK

`build.hands:hands-android-sdk` is the Android SDK for Hands. It handles
in-app update checks and installation, staged-rollout device bucketing,
feedback submission, and crash reporting.

## Install

Consume the SDK from **JitPack (no token)** or from **GitHub Packages (needs a
`read:packages` token)** — the GitHub Packages Maven registry requires a token on
every request even for public packages, so JitPack is the simpler choice.

### JitPack — no token

```kotlin
// settings.gradle.kts or the module repositories block
repositories {
    maven { url = uri("https://jitpack.io") }
}

dependencies {
    implementation("com.github.botiverse:hands:android-sdk-v0.13.0")
}
```

### GitHub Packages — needs a `read:packages` token

```kotlin
repositories {
    maven {
        url = uri("https://maven.pkg.github.com/botiverse/hands")
        credentials {
            username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
            password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN")
        }
    }
}

dependencies {
    implementation("build.hands:hands-android-sdk:0.13.0")
}
```

Configuration (`BuildConfig` fields are a convenient place to keep these):

| Value | Example |
|---|---|
| Base URL | `https://hands.build` |
| App slug | `raft-android` |
| Channel | `main` / `preview` / `nightly` / `debug` |

## Update checks and installation

`UpdateChecker` hits the public update-check endpoint, compares
`versionCode`, and (optionally) downloads + installs the APK.

```kotlin
val checker = UpdateChecker(
    context = applicationContext,
    baseUrl = BuildConfig.HANDS_BASE_URL,
    appSlug = BuildConfig.HANDS_APP_SLUG,
    installedVersionCode = BuildConfig.VERSION_CODE.toLong(),
    channel = BuildConfig.HANDS_CHANNEL,
    arch = Build.SUPPORTED_ABIS.firstOrNull(),
    // Pass the app-selected locale; the SDK never guesses system locale.
    languageTag = currentAppLanguageTag,
    eventListener = HandsUpdateEventListener(updateDiagnostics::append),
)

// Idempotent: checks only from a restartable state, waits for active work,
// and reopens the same verified APK after returning from the system installer.
val status = checker.install()
```

Call `checker.status()` on update-page/lifecycle resume and map the stable
lower-case states: `idle`, `checking`, `patching`, `downloading`,
`ready_to_install`, `installer_opened`, `failed`, `stale`, and `installed`.
`installed` is reached only after PackageManager observes the exact target
version. If the user returns without accepting the system installer, the state
remains installable; `reopenPendingInstaller()` reopens the same APK without a
duplicate download.

The SDK persists a package/origin/app/channel/product/platform/architecture
bound transaction containing target/build/asset identity, DownloadManager id,
and the expected app-scoped file. It deliberately does not persist signed
download URLs or auth material. Resume reconciliation checks installed version,
DownloadManager, path ownership, file size, SHA-256 when supplied, exact APK
package/target version, and signing certificates. Missing, changed, or unsafe
inputs fail closed as `stale`/`failed` with a stable `errorCode`.

`HandsUpdateEventListener` receives structured check, delta download/apply/
validation/fallback, full-download, content-URI, installer, reconciliation,
failure, and cleanup events for inclusion in host feedback diagnostics; Logcat
is not the only telemetry path. Valid explicit BCP-47 `languageTag` values are
sent as `X-Hands-Lang` and `Accept-Language`. Missing/malformed values send
neither header, while unsupported languages retain the server's English
fallback. Events/status include requested and resolved language tags.

The `0.12.4` and newer AARs use 16 KB-compatible ELF `PT_LOAD` alignment for
the 64-bit native crash library. Publication verifies the AAR and its matching
unstripped native-symbols archive before either artifact is released.

The SDK sends a stable per-install id (`X-Hands-Device-Id`, from
`HandsDeviceId`; the server still accepts the legacy `X-Quiver-Device-Id`) so
the server can bucket the device for **staged rollouts**
— a release published at 25% only installs on the matching fraction of
devices, and each device keeps its bucket as you raise the percentage.

To check without installing (e.g. to render your own "update available" UI),
call `HandsClient(baseUrl).checkForUpdate(..., languageTag = ...)` and act on
the result.

## Feedback

`HandsFeedback` submits user feedback (with attachments) to the Hands
ticket system; it auto-attaches app/device metadata and the device id.

```kotlin
val ticketId = HandsFeedback(
    context = applicationContext,
    baseUrl = BuildConfig.HANDS_BASE_URL,
    appSlug = BuildConfig.HANDS_APP_SLUG,
    versionName = BuildConfig.VERSION_NAME,
    versionCode = BuildConfig.VERSION_CODE.toLong(),
    channel = BuildConfig.HANDS_CHANNEL,
    clientKey = BuildConfig.HANDS_CLIENT_KEY,   // app Settings → Client key
).submit(
    message = "Feed doesn't refresh after login.",
    kind = "bug",                       // "feedback" | "bug" | "crash"
    contact = "user@example.com",       // optional
    attachments = listOf(screenshot),   // up to 9 files, 200 MB each (large files upload directly to storage via presigned URLs)
)
```

Tickets appear in the app's **Feedback** tab and are triageable from the
admin console or the CLI (`hands feedback ...`).

## Crash reporting

`Hands.install(...)` captures uncaught JVM exceptions and NDK/native
crashes and uploads them as `kind=crash` tickets, grouped by signature and
symbolicated in the console.

```kotlin
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Hands.install(
            context = this,
            baseUrl = BuildConfig.HANDS_BASE_URL,
            appSlug = BuildConfig.HANDS_APP_SLUG,
            versionName = BuildConfig.VERSION_NAME,
            versionCode = BuildConfig.VERSION_CODE.toLong(),
            channel = BuildConfig.HANDS_CHANNEL,
            clientKey = BuildConfig.HANDS_CLIENT_KEY,
            // optional: attach app-specific context (recent logs, etc.)
            extraContext = { myDiagnostics.recentText() },
        )
    }
}
```

To get readable (deobfuscated) stacks in the console, publish the release
with its R8/ProGuard `mapping.txt` (and, for NDK crashes, the unstripped
`.so` archive) — Hands symbolicates crash reports for that `versionCode`
automatically. QNC2 native reports preserve the crashing thread's PC/LR/SP,
registers, thread identity, and loaded-image ELF BuildIds. Symbolication is
strictly fail-closed: the crash frame's BuildId must exactly match an ELF in
the uploaded archive; Hands never guesses by version or filename. On Android
11+ the SDK also attaches the matching `ApplicationExitInfo` trace and exit
description when the OS retained them, providing the sanctioned
tombstone/abort-message equivalent without reading `/data/tombstones`. The
match requires the recorded process id, native-crash reason, and a narrow
timestamp window; retained evidence is assigned one-to-one and persisted for
deterministic retry rather than guessed from the nearest package exit.

### ANRs

Since `0.13.0`, on Android 11+ (API 30) the SDK also reports ANRs
(Application Not Responding kills). The system kills the process from outside,
so nothing runs in-process; on the next launch the SDK reads
`ApplicationExitInfo` entries with `REASON_ANR` and submits each one once as a
`kind=crash` ticket with `crash_type=anr`. The system ANR thread dump is
attached (and R8-retraced like a Java crash log). The ticket is grouped as
`ANR@<first app frame on the main thread>`, so the same freeze clusters
across builds and never merges with a Java exception on the same frame. The
console's Crashes page can filter by type (Exceptions / Native / ANRs).

Reporting is exactly-once: a persisted watermark advances only after the
server accepts a ticket, so a failed upload retries on the next launch. The
first launch with this SDK looks back 7 days, and at most 3 ANRs are sent per
launch. Pass `captureAnrs = false` to `Hands.install(...)` to opt out. Below
API 30 this is a no-op.

## Release health

`Hands.install(...)` tracks foreground sessions automatically. A session ends
after the app has remained in the background for 30 seconds; a quick activity
or configuration transition stays within the same session. Start/end/crash
events are committed on-device before delivery and retried after a network or
process failure.

The app overview uses these events to show crash-free sessions and crash-free
devices by version and channel. Pass `trackSessions = false` to
`Hands.install` only when the host app intentionally opts out of release-health
telemetry.

## Device analytics

`Hands.install(...)` already sends a lightweight launch/install metrics ping (throttled
to ≤1/day/install) that powers the console's active-device and
version-distribution views — no separate call needed. This is not a true
online heartbeat. No PII: only the random per-install device id and build/OS
metadata. Pass
`reportDeviceAnalytics = false` to `Hands.install` to opt out.

## Notes

- All network calls are suspend functions; call them off the main thread.
- The device id is a random UUID in SharedPreferences, not a hardware id; it
  resets on reinstall/clear-data, which is fine for rollout cohorting.
- The client key (`qk_…`) authenticates feedback/crash submissions. It ships
  inside the APK (Sentry-DSN model — app identifier, not a user secret); get
  it from the app's Settings tab and put it in `BuildConfig`.
