The Roadie Android SDK is a Kotlin client for chat, embeddings, and images. It calls the native data plane at /v1 for chat and embeddings, and the OpenAI-compatible surface at /openai/v1 for images. It authenticates each device anonymously — no login screen and no provider key in the app — via Google Play Integrity. Roadie holds your provider keys (BYOK), meters usage per device, and enforces free-vs-pro limits. It ships as two artifacts so you only pull in Play Services when you need them:
  • roadie-core — the full data-plane surface (chat streaming + non-streaming, embeddings, images, feedback) with static-token auth. A pure Kotlin/JVM library (OkHttp + kotlinx.serialization + coroutines) with no Android-framework dependency, so it builds and unit-tests with just Gradle and a JDK, and is consumable from any Android app.
  • roadie-playintegrity — the PlayIntegrityTokenProvider and its Android Keystore + Play Integrity plumbing. Add it for production device auth; it depends on roadie-core.
The two token providers are interchangeable at the call site — build with StaticTokenProvider during development and switch to PlayIntegrityTokenProvider in production without touching a single chat, embeddings, or images call.

Install

Add the artifacts to your app module’s build.gradle.kts. roadie-playintegrity brings roadie-core in transitively, so a production app that uses device auth only needs the one dependency; a dev-only or server-side integration that supplies a static token can depend on roadie-core alone.
build.gradle.kts
The source lives at github.com/paroaria/roadie-android. Roadie is pre-1.0 (0.x); changes inside /v1 are additive.

Requirements

Core library desugaring for minSdk < 26. roadie-playintegrity uses java.time (to parse the minted token’s expires_at) on the API-24 baseline. If your app’s own minSdk is below 26, the desugared runtime is only linked when the consuming app opts in — so you must enable core library desugaring in your app module:
build.gradle.kts
Apps with minSdk >= 26 (where java.time is native) need none of this. roadie-core alone does not require it.

Configure the client

A Roadie client needs a RoadieConfig (where the gateway lives, and which image model to request) and a token provider (how each request is authenticated). Swap the provider between local development and production without changing any call site:
  • StaticTokenProvider — a fixed bearer (an rd_sk_… key or a pre-minted client token). The simplest dev path; read it from a build config field or an environment variable, never a committed literal.
  • PlayIntegrityTokenProvider — silently mints a per-device client token via Google Play Integrity, using the publishable key. The production path; it needs a real device with Google Play Services (Play Integrity is unavailable on bare emulators).

RoadieConfig

Keep configuration out of source: inject the base URL, publishable key, and static token through BuildConfig fields (from Gradle properties or the environment) rather than committing literals, so a secret is never shipped in a Release build.

Method surface

The Roadie client exposes four resource surfaces — chat, embeddings, images, and feedback. Every method except chat.stream is a suspend function; chat.stream returns a cold Flow.
model accepts an explicit provider/model id (like openai/gpt-4o) or a project alias you configure (like smart) that fails over across providers. See Models, aliases & fallback.

Chat

chat.create runs a non-streaming completion and returns the normalized ChatResponse — the assistant message, the finishReason, token usage, an estimated cost, the resolved provider/model, and the gateway requestId. ChatResponse.text concatenates the reply’s text parts for you.
Build messages with the ChatMessage companion helpers — ChatMessage.system(…), .user(…), .assistant(…), and .tool(content, toolCallId). The wire field for the output cap is max_output_tokens (maxOutputTokens in Kotlin), not max_tokens. stream is managed by the SDK — create sends false, stream sends true — so you never set it.

Stream a completion

chat.stream returns a cold Flow<ChatStreamEvent>; the HTTP connection is opened lazily when you start collecting. Concatenate ContentDelta.delta chunks to build the reply, and read the final usage/cost from the terminal MessageEnd. Cancelling the collector cancels the underlying connection.
ChatStreamEvent is a sealed interface with one case per SSE frame: MessageStart, ContentDelta, ToolCallDelta, UsageEvent, MessageEnd, and a terminal Error. Transport, decoding, and pre-stream HTTP failures are thrown from the flow; a mid-stream error frame is instead yielded as a terminal ChatStreamEvent.Error(error: RoadieException) — nothing follows it. See Streaming.

Embeddings

embeddings.create embeds one or more inputs and returns them in input order. EmbeddingResponse.vectors gives the raw List<List<Double>>; the request accepts either a single String or a List<String>.

Images

The image surface speaks the OpenAI-compatible shape the gateway exposes under /openai/v1. Both generate and edit return the generated image’s raw bytes (PNG unless the server’s output_format is changed) — turn them into a Bitmap with BitmapFactory.decodeByteArray.
size is an ImageSize (AUTO, SQUARE, LANDSCAPE, PORTRAIT), background is an ImageBackground (TRANSPARENT, OPAQUE, AUTO), and quality is an ImageQuality (LOW, MEDIUM, HIGH, AUTO). n defaults to 1. The two operations default background differently — generate defaults to OPAQUE, edit to TRANSPARENT.
ImageSize.forAspectRatio(ratio) maps a width ÷ height ratio to the closest concrete size (> 1.2 → LANDSCAPE, < 0.83 → PORTRAIT, otherwise SQUARE).

Feedback

feedback.submit records a thumbs rating for a prior chat or embeddings response, keyed by that response’s gateway requestId.

Authentication providers

Every request resolves its Authorization: Bearer … value through a TokenProvider. Swapping the provider — a static token in dev, PlayIntegrityTokenProvider in production — changes nothing at the call site.
  • StaticTokenProvider(token) returns a fixed token; invalidateToken() is the interface default (a no-op), so the transport’s single remint-on-401 retry re-sends the same token exactly once and never loops.
  • PlayIntegrityTokenProvider(context, config, options) mints short-lived client tokens by proving the app is a genuine build on a real device — a Google Play Integrity classic verdict plus a hardware-backed Android Keystore EC key — and exchanging that proof at the gateway’s /v1/device/android/{challenge,attest,assert} routes (authorized with the publishable key). No end-user account and no secret key; the device becomes the end user. It requires a publishableKey in the config and a real device with Google Play Services. Only the durable device key (Android Keystore) and the onboarding time (EncryptedSharedPreferences) persist; the client token lives only in memory and is re-minted before expiry (or after a 401).

Attest, assert, and re-attest

The first run — and any run whose last attest is older than PlayIntegrityOptions.attestRefreshInterval (default 12 hours) — performs a full attest: a fresh Play Integrity verdict. Every other mint uses the cheap Keystore assert, keeping Play Integrity off the hot path. The periodic re-attest is what lets the gateway (re)write the durable Device Recall “free consumed” bit within the refresh window after a device exhausts its quota — Roadie’s reinstall-proof free-tier guard (the Android analog of iOS DeviceCheck). refreshAttestation() forces an immediate attest. Call it when the device has just become relevant to the free-tier guard — for example, right before showing a paywall — so the gateway writes the Device Recall bit now rather than at the next refresh window.
PlayIntegrityOptions carries the tuning beyond the gateway location + publishable key:
Device Recall is wired internally — there is no separate provider to construct — and it fails open (an unsupported device or a write failure simply grants the normal free tier and never blocks a mint). Enabling it is an operator step: see Android device auth setup and Mobile device auth.

Error handling

Every HTTP or decoding failure is thrown as a RoadieException; a cancelled coroutine surfaces kotlinx.coroutines.CancellationException, which the SDK never wraps. RoadieException is a sealed class, so branch with is / when:
Envelope-mapped server errors (one per gateway error type): InvalidRequestException, AuthenticationException, PermissionException, NotFoundException, RateLimitException, QuotaException, BudgetException, ProviderException, IdempotencyException, InternalException. A content-policy rejection maps to InvalidRequestException. Client-side errors: ApiConnectionException, ApiConnectionTimeoutException (a subclass of ApiConnectionException, but deliberately not retried), ApiDecodingException, and RoadieConfigurationException. Every RoadieException carries status, code, requestId, param, docUrl, and — for a rate limit — retryAfter. The SDK handles two cases for you: it re-mints once on a 401 (an expired client token) and retries transient network and 5xx failures, honoring Retry-After and reusing one auto Idempotency-Key across those retries. A client timeout (ApiConnectionTimeoutException) is deliberately not retried — an image generation runs for tens of seconds, so a timeout usually means the server is still working, and a retry would double-execute an expensive call. A QuotaException (insufficient_quota) drops the cached token and is re-thrown unchanged (no retry) — your cue to show the paywall. See Errors & retries and the full error reference.

Next steps

Android quickstart

Add the SDK to an app end to end and send your first chat.

Android device auth setup

Configure Google Play Integrity so the SDK can mint per-device tokens.

Mobile device auth

How anonymous device attestation mints client tokens with no account.

Free → Pro entitlements

Turn a free device into a paying user.