Android SDK (Kotlin)
The Orbit Android SDK embeds Orbit’s end-user-facing surfaces into a native Android app: in-app chat (the same omnichannel conversation/inbox the web widget and iOS SDK use), owned in-app messages and content cards, and video rooms. It ships as a single Gradle module with three clients —OrbitChatClient, OrbitInAppClient, and OrbitVideoRoomClient — and targets Android API 21+ (Kotlin, coroutines, kotlinx.serialization, OkHttp).
This is a client SDK: it authenticates only with a publishable key (dv_live_pk_…), because it ships inside a distributed app binary. It intentionally does not wrap the server-side management resources (messaging, voice, contacts, campaigns, verify) — those require a secret key and belong in your backend, via the Node SDK or the REST API. See Client / mobile SDKs (different scope, by design) on the SDK index.
Pre-publish — source-only. The Android SDK is not yet on Maven
Central — the Gradle dependency line below fails today. Until first
publish, vendor the source from the monorepo (
packages/sdk-android/)
and include it as a Gradle project dependency.Scope map: what the SDK covers, and what it deliberately does not
The three clients cover end-user surfaces — the things the person holding the phone sees. Everything a tenant’s operator configures runs through your backend with a secret key, and none of that ships in the app binary.
Integration planning rule: if the call belongs to a visitor (read the feed,
send a chat message, join a room), it belongs in the app; if it belongs to the
tenant (create the room, author the campaign, send the outbound message),
it belongs in your backend.
Installation (Gradle)
Version compatibility
The module’s build contract is fixed by its own Gradle configuration — match your app against these floors:
AGP 8.5.0 builds the library. All network methods are
suspend, so call them
from a coroutine (for example lifecycleScope / viewModelScope); the video
client additionally needs a Main-dispatcher engine callback surface.
Headless chat client (OrbitChatClient)
The client ships zero UI — it owns the conversation lifecycle, history, optimistic send, and the real-time inbound stream; your Activity, Fragment, or Compose screen renders it. All network methods aresuspend functions.
on… method returns an OrbitSubscription; call .cancel() on it to stop receiving the event. Call chat.disconnect() to stop the real-time stream, and chat.close() when the client is done (it cancels the internal coroutine scope — the client cannot be reused after that).
Send a file attachment (10 MB client-side guard):
Storage adapter contract (OrbitChatStorage)
Chat persistence is an injectable three-method interface — pass it so the active conversation id survives process death and the next cold launch resumes the same conversation instead of creating a fresh one:SharedPreferencesStorage(context) as the built-in
implementation (one SharedPreferences file, MODE_PRIVATE). Any other
key/value store — DataStore, an encrypted store, a Room table — satisfies the
contract equally, as long as writes are durable before the client reads them
back on the next launch:
- Reads must be synchronous. The interface is non-
suspendon purpose, because the client consults storage on its own IO dispatcher while building the first request. A DataStore flow that only emits asynchronously will deadlock or race; bridge it (or use the shipped SharedPreferences implementation, which needs no bridge). - Omit storage and the session is process-scoped. With
storage = nullthe client keeps the conversation for the process lifetime only — correct for a guest-checkout flow, wrong for a support inbox.
OrbitInAppStorage, same
three-method shape minus remove) so the sticky anonymous id and the
locally-dismissed surfaces survive restarts. Import
io.orbit.devotel.inapp.SharedPreferencesInAppStorage for the shipped
implementation.
Lifecycle ordering: connect → subscribe → send → disconnect → close
.close() is terminal — it cancels the client’s internal coroutine scope, so
calls made after it throw on a cancelled scope. Order the lifecycle
explicitly, and create one client per Activity/ViewModel that you then close
in onDestroy / onCleared:
The two safe teardown shapes:
join() → leave() on the engine-injected client:
leave() disconnects the underlying VideoRoomEngine (releases camera/mic),
and the client carries no own coroutine scope to close.
In-app messages and content cards (OrbitInAppClient)
Owned in-app surfaces — assembled server-side into a per-visitor feed, rendered locally, with impression / click / dismiss reported back. Dismissals persist across launches through the storage adapter.reportEvent takes the InAppEventType enum, not a raw string — a string
literal ("impression") or an alias ("page_view") sails through the call
and is then rejected by the server with a 422. The closed set and the fix per
language are in InAppEventType: the in-app event enum.
feed.cards carries the surfaces your tenant published without-you-seeing-them
fetched; report exactly one impression per rendered surface and keep
dismiss(...) idempotent — the client maintains the local dismissed set (and
persists it through the storage adapter when you passed one). anonymousId()
exposes the sticky anon id the targeting rule matched against.
Video rooms (OrbitVideoRoomClient)
Headless and engine-injected: your backend mints the room token server-side (POST /api/v1/video/rooms/:id/join) and hands the token plus server URL to the app. Wire the client over a VideoRoomEngine backed by a WebRTC engine (for example the Orbit Media SDK for Android), so this package carries no hard WebRTC dependency.
Media-engine selection
The client implements the state and protocol; the engine implements the WebRTC bits. TheVideoRoomEngine contract is deliberately small — connect,
disconnect, the three publish toggles, and the local-participant accessor:
Either way, the client is engine-agnostic: swap the constructor arg and the
room-join / captions / breakout logic is unchanged, because the engine only
moves media and the client owns all state and event fan-out.
joinBreakout(name, token) and returnToMainRoom(...) let you move a
participant between the main room and a named breakout without rebuilding the
client. The publish toggles (toggleMicrophone(), toggleCamera(),
toggleScreenShare()) return the new enabled state so a toggle button can
bind directly on the result.
Push token onboarding (backend)
The Android SDK ships chat, in-app, and video clients; registering the device’s FCM token for push delivery is a backend call againstPOST /api/v1/push/device-tokens, run with a secret key via the
Node SDK or plain HTTP:
Runnable examples
The package’sexamples/ directory holds self-contained end-to-end Kotlin files — each takes the publishable key from ORBIT_PUBLIC_KEY (dv_test_pk_… / dv_live_pk_…): ChatConnectSend (chat connect + send with OrbitApiError.status + retryAfterSeconds handling) and InAppFeed (/sdk/in-app/feed fetch + IMPRESSION + dismiss(surfaceId)). Swap sandbox vs live by changing the env value — no code edits. See packages/sdk-android/examples/README.md for how to load the key from local.properties via BuildConfig. FCM device tokens stay backend-only (POST /api/v1/push/device-tokens), so there is no client FCM sample to keep the distributed-binary invariant intact.
Error handling
API failures surface as a typedOrbitApiError carrying the HTTP status and the server response excerpt; a parsed Retry-After lets a 429 be throttled instead of retried in a hot loop. Construction misuse (empty key or a server-side secret key) throws IllegalArgumentException; attachment-size checks throw OrbitChatException.
Troubleshooting with the status mapping
Every client in the module raises the sameOrbitApiError(status, responseExcerpt, message, retryAfterSeconds) — branch on status first, and read responseExcerpt only for diagnostics (it is capped at 200 chars).
Local, pre-network failures never reach
OrbitApiError — construction-time
validation (empty key, or a secret-key prefix dv_live_sk_… /
dv_test_sk_…) throws IllegalArgumentException, and the attachment 10 MB
guard throws OrbitChatException before any bytes move.
Only the publishable key (dv_live_pk_… / dv_test_pk_…) may be embedded in the app. The clients refuse a server-side secret key (dv_live_sk_… / dv_test_sk_…) at construction, because anything bundled in a distributed binary is extractable by any user. Real-time updates use header-authenticated polling, so the key never lands in a URL log.