Skip to main content

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 are suspend functions.
Each 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:
The SDK ships 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:
Two rules keep the adapter honest:
  1. Reads must be synchronous. The interface is non-suspend on 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).
  2. Omit storage and the session is process-scoped. With storage = null the client keeps the conversation for the process lifetime only — correct for a guest-checkout flow, wrong for a support inbox.
The in-app client has its own storage adapter (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:
For video, the symmetry is 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. The VideoRoomEngine 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 against POST /api/v1/push/device-tokens, run with a secret key via the Node SDK or plain HTTP:
No SDK in the backend? The same call over HTTP:
Re-registering an existing token is an upsert — call it on every FCM token rollover and last-seen is bumped, never duplicated. Delivery-side mechanics (categories, rich media, scheduling) live in the push channel reference.

Runnable examples

The package’s examples/ 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 typed OrbitApiError 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 same OrbitApiError(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.