Android (Kotlin)
The in-app agent in a native Android app — screen context, actions, components and voice, from one Gradle line and no dependencies.
The Android SDK is a Kotlin library with no runtime dependencies — no AndroidX, no OkHttp, no Compose, no Material — so it drops into any app, including one that has to pass a bank's dependency review. It hosts the same voice engine as every other Graine SDK and is tested against the same contract: every masking rule, connection-quality threshold and component look is checked against the answers the React Native SDK gives.
Install
The source, a sample app and the tests are at github.com/GraineAI/inapp-android.
Android 7.0 (API 24) and up. The library's manifest adds INTERNET,
RECORD_AUDIO and MODIFY_AUDIO_SETTINGS for voice, and what a call needs —
POST_NOTIFICATIONS, USE_FULL_SCREEN_INTENT, FOREGROUND_SERVICE,
FOREGROUND_SERVICE_MICROPHONE, the call screen and its service and receiver
(Calls). Its ProGuard rule
ships inside it, so there is nothing to copy. The SDK asks for the microphone
itself, when a call starts; notifications are yours to ask for.
Two dashboard steps, the same as every platform: give the app
its own agent, and add app://
to its Allowed domains under Embed & Widgets.
Calls over WebRTC (optional)
The native calls — the customer calling the agent, and a call Graine rang — carry their audio over WebRTC when you add the WebRTC module beside the SDK, at the same version:
Nothing else changes — not your code, the ring, the call screen or the outcomes. The SDK finds the module by itself; without it, calls run on the WebSocket as before, and the SDK keeps no dependencies.
- What the customer notices: a call that holds up better on mobile data and connects as fast as before — it is prepared at the tap, or while the phone rings. Over WebRTC the agent's voice arrives at telephone bandwidth today.
- When WebRTC cannot carry a call, it continues on the WebSocket inside the same ring and screen: no second ring, no error.
- The dashboard decides last: the agent's In-app call audio setting (Calls & scheduling) — WebRTC (recommended) or Standard — can move every install's calls to the WebSocket from their next start, without a release.
- Size: WebRTC's native library, per phone (an app bundle delivers one ABI): about 7.4 MB installed on 64-bit ARM (2.5–3.3 MB to download), 4.3 MB on 32-bit ARM, 8.2–8.4 MB on x86, and about 0.5 MB of classes.
- Permissions and licences: the module's manifest adds
ACCESS_NETWORK_STATE(a normal permission, granted at install: nothing is asked); its R8 rules ship inside it. Addgraine-inapp-rtc/THIRD-PARTY-NOTICES.md(WebRTC, BSD 3-Clause) to your app's open-source licences.
The choice in code, and which one ran: Calls over WebRTC.
Quickstart
1. Initialise once
init must run before any bar is created, which Application.onCreate guarantees.
2. Mount the engine and the bar
The bar places the call, asks for the microphone when it has to, and says what
the network is doing. To draw the call yourself instead, leave the bar out and
draw from graine.voice — Your own UI.
3. Tell it what is on screen
Call it whenever the screen or a field changes; bursts go out as one frame, and an unchanged screen is not resent. Report real values: PAN, Aadhaar, card, phone and email are masked on the device before anything leaves it.
4. Components
Nothing to do: the bar draws the components the agent shows — product lists in five looks, plan comparisons, choices, time slots — and answers the agent with what the customer picked. See Components.
Several activities, one call
Attach the engine once. From then on it follows the customer: whichever of your activities comes to the front, the one engine moves into it and a call in progress carries on. A KYC flow built as one activity per step does not hang up on "Next".
Each activity that should show the agent places its own GraineAgentBarView;
a bar created mid-call shows the live call at once. Do not call
voice.detach() from onDestroy — that ends the call the next activity is
carrying. Backgrounding the app still ends it, after a two-second confirmation.
Jetpack Compose
The views are plain Android views, so Compose hosts them with AndroidView:
Java
Every entry point works from Java with only the arguments you need:
new GraineConfig(baseUrl, publishableKey), ActionResult.ok("Done."),
new GraineScreen("kyc_documents"). GraineListener has default methods, so a
Java listener overrides only the callbacks it wants.
Calls: the ring, the call screen, the screen off
A WhatsApp-style call with your agent: the customer calls it, or your server
rings the customer with a push — a call notification that fills a locked
screen, Answer and Decline, the call going on with the screen off. calls
runs the call's audio natively in a foreground service, with the SDK's own call
screen (or yours). The library's manifest brings everything it needs. 0.6.0
Add Firebase to your app; the SDK registers for pushes itself. With
Firebase Cloud Messaging in the app (google-services.json, its Gradle plugin and
com.google.firebase:firebase-messaging), the SDK's own messaging service rings
each call push and receives every new token, and the SDK reads the current token
when it starts. No messaging service of yours.
Already have your own FirebaseMessagingService? It outranks the SDK's
(declared at priority -1) and receives every message, so hand Graine's over —
two lines:
The push your server sends, the call screen and what to test:
In-app calling. With the
WebRTC module in the app, both directions carry
their audio over WebRTC; GraineCallsConfig(callTransport = …) chooses and
calls.transport says which one ran. 0.9.0
Not using calls? The library's manifest adds what a call needs to every app
that includes it. On Android 14 and later, Google Play asks apps that declare
USE_FULL_SCREEN_INTENT or FOREGROUND_SERVICE_MICROPHONE to explain why in
the Play Console. An app that never places a call removes them, and the call's
microphone service, in its own AndroidManifest.xml:
Reference
GraineConfig
| Option | Default | |
|---|---|---|
baseUrl | — | https://www.graine.ai |
publishableKey | — | Selects the agent. Public by design: it cannot read data or place a phone call |
variables | empty | Prompt variables known at build time |
transport | the dashboard's | Force Transport.WEBRTC or Transport.WEBSOCKET, for testing |
appVersion | — | Lands on the call record, to tell builds apart |
endOnBackground | true | End a call two seconds after the app leaves the foreground |
timing | Load watchdog and retry ladder: stallMs, bootMs, hardCapMs, retryDelaysMs, endWatchdogMs | |
friction | — | Your layer of the stuck-customer rules, over the dashboard's |
debug | false | Every bridge message and the engine's console in logcat; the engine inspectable in chrome://inspect |
chatSize | the dashboard's | How big the chat window opens: GraineChatSize.COMPACT, LARGE or FULL; chat.open(size) wins for one open — Window size |
GraineInApp
| Member | |
|---|---|
identify(traits) | Who the customer is. null on sign-out |
setVariables(values) | Merged in; null under a key removes it |
currentVariables() | Dashboard defaults, then build time, then runtime, then identity — last wins |
setScreen(screen) | What the customer can see |
track(name, data) | A product event that informs without interrupting |
registerAction(name) { args -> result } | One handler per action declared in the dashboard. Returns an unregister function |
checkActions() | Which declared actions this build handles, and which it does not |
noteInteraction() | The customer did something that is not a field edit; the stall clock restarts |
addListener / removeListener | Events, below |
answerWidget(widgetId, data, summary) | Answer a component from a UI of your own, on the conversation that is live. 0.9.0 |
voice | The in-app engine's call — the bar |
calls | Native calls: the ring, the call screen, the screen off — below. 0.6.0 |
GraineVoice
| Member | |
|---|---|
attach(activity) | Mount the engine, once. Later calls move it |
start() / end() / setMuted(m) | Place, hang up, mute. end() is guaranteed: a page that never confirms is torn down after four seconds |
connected, connecting, muted, ready | Call state |
quality | EXCELLENT, GOOD, POOR, LOST or UNKNOWN |
online, reconnecting, connectivity | What the network is doing, and whether the engine loaded |
retry() | Reload the engine, for a "Try again" button |
captions, captionsOn, setCaptionsOn(on), clearCaptions() | What was said (GraineCaption: role, text, live — the last 12), and the captions switch: on by default; off, the bar hides its caption while captions keeps what was said; clearCaptions() forgets it without ending the call. 0.9.0 |
attached, viewOpened(), viewClosed() | Whether the engine is mounted; a voice UI of your own on screen counts as the bar does. 0.9.0 |
phase | idle, connecting, reconnecting, thinking, speaking, listening — one thing to draw from. 0.3.0 |
error | Set when the call cannot work until something is fixed. 0.3.0 |
connectStage, slowConnect, callFailed | Where a call being placed is (REGISTERING, DIALLING, RETRYING, SWITCHING), whether the customer has waited past timing.slowConnectMs (4 s), and whether nothing could carry the call — start() tries again. The bar already says all three. 0.5.0 |
detach() | Remove the engine entirely, hanging up first |
GraineCalls
Main thread, except handlePush, which is safe from the messaging service's
thread. 0.6.0
| Member | |
|---|---|
configure(config) | How calls look and ring (GraineCallsConfig). Once, in Application.onCreate |
handlePush(data) | A push's data map. true for any call push (type: agent_call) — it rings, is reported missed during another call, or, without a call_id, is dropped with a warning — and false for anything else |
GraineCalls.handlePush(context, data) | The same, from a messaging service of your own without reaching for the instance: if (GraineCalls.handlePush(this, message.data)) return. With Firebase in the app and no service of yours, the SDK's own service does it |
GraineCalls.onNewToken(context, token) | Firebase's token for this install, from the onNewToken of a messaging service of your own — kept on the device and registered with Graine when Graine rings your customers (Hosted ringing). Without one, the SDK receives each token and reads the current one at start |
startCall(activity, reason, variables) | The customer calls the agent. Returns the call's id, null while a call is live |
answer(), decline(), end() | From your own call screen; the SDK's does this for you |
setMuted(value), setSpeaker(on) | Mute; the loudspeaker (otherwise a connected headset, else the earpiece) |
onRequestPermissionsResult(code, results) | Forward from your own call screen (callActivity): the SDK asks for the microphone there. The SDK's own screen needs nothing |
state | IDLE, RINGING, CONNECTING, ACTIVE, ENDED |
call | The call in progress: callId, direction, callerName, reason, variables |
muted, speaker, agentSpeaking, caption, connectedAt | What to draw. caption is (who, text, still being said); connectedAt is SystemClock.elapsedRealtime() milliseconds |
transport, transportFallbackReason | What carries the call's audio — "webrtc" or "websocket" — and why a call that would have used WebRTC is on the WebSocket. Final once the call is ACTIVE. 0.9.0 |
addListener / removeListener | GraineCallListener |
GraineCallsConfig(displayName, smallIcon, ringtone, callActivity, ringTimeoutMs, callTransport) —
the name on the ring and the call screen ("Assistant" unless you set one; a
push's caller_name wins), a
monochrome notification icon, the ring (the phone's ringtone by default), your
own call screen instead of GraineCallActivity, how long a ring lasts
before it is missed (30 s), and what carries the calls' audio:
GraineCallTransport.AUTO (the default — WebRTC when the module is in the app),
WEBSOCKET or WEBRTC.
GraineCallListener — onIncomingCall, onCallState, onCallOutcome(callId, outcome) (outcome.wire: answered, declined, missed, ended,
failed; outcome.endReason with ended, outcome.error saying why),
onCallCaption, onAgentSpeaking and onCallControls. Every
method is optional; all are called on the main thread.
GraineAgentBarView
The bar is drawn in the brand's design, exactly as the dashboard's Garage previews it: the design's surface, corners and shadow (a hard offset for Neo-Brutalist, frosted for Liquid Glass), the accent avatar with the launcher glyph, the name and status in the design's typeface with a meter that moves at the design's pace, the microphone, the hang-up and the caption card — and the dashboard's loader while the call connects, reconnects or the agent thinks. The SDK downloads the design's typeface from Graine and registers it itself; there is nothing to bundle and nothing to configure. 0.3.0 — Your design
From 0.7.0 the bar carries the chat too: where the dashboard offers both, it
shows a switch and opens the chat window in your design. To place the chat
yourself, add a GraineChatView, or open GraineChatActivity.open(context) —
Chat. A chat of your own reads chat.reconnecting (true while
a dropped chat is being made again — say Reconnecting…, not Offline), and lays
the conversation out as the SDK's window does with ChatRows.build(messages) — a
bubble per message, one Voice call row per earlier call, a day line where the
day changes. 0.9.0
Each property overrides one thing:
| Property | |
|---|---|
accent, name, avatarUrl, scheme, radius | Brand colour, heading, face, light / dark / auto, corner rounding |
draggable | The customer can drag the bar up and down; a tap still taps. Default true |
placementOffset | Restore a position you saved |
onPlacementChange | The customer let go somewhere new: save it. The SDK stores nothing |
keyboardAware | Rise above the soft keyboard. Default true |
widgetView | The components drawn on the bar, which move with it |
onClose | The end button was pressed with no call — hide the bar if your app shows it on demand. 0.3.0 |
GraineWidgetView is the component renderer on its own, for a layout that
draws components somewhere other than the bar.
GraineListener
Every callback is optional.
| Callback | |
|---|---|
onSessionReady(session) | The agent's configuration loaded |
onCallState(connected, connecting) | Call state changed |
onCaption(role, text, live) | A line being said |
onWidget(widget) / onWidgetPending(hint) | The agent drew a component, or is about to |
onEnded() | The call is over, however it ended. Once |
onMicDenied(reason) | The microphone was refused |
onQuality(quality), onNetwork(online), onReconnecting(active) | The connection |
onTransport(transport, requested, reason, message) | Which transport carries the audio |
onActionRequested(name) / onActionCompleted(name, result) | An action ran |
onFriction(signal) | The customer looks stuck, and the agent has been told |
onError(message) | Loud for you, invisible to the customer |
Troubleshooting
| Symptom | Check |
|---|---|
Nothing happens, onError says 403 | app:// is missing from the agent's Allowed domains |
IllegalStateException: GraineInApp.init … has not been called | A bar was created before Application.onCreate ran init |
| The call ends when the customer moves to another activity | Something calls voice.detach() in onDestroy. Remove it |
| The agent does not know the screen | setScreen is not called on that screen, or is called before the call connects and never again — call it on every change |
| "Sorry, I can't do that here" | The action is not declared in the dashboard, or its name differs from registerAction |
| What went over the bridge? | debug = true: every message both ways is in logcat under Graine, with values redacted |
| A push does not ring when the app is closed | Send a data-only FCM message (no notification block) at high priority, and call calls.handlePush(message.data) first in onMessageReceived |
| The ring is a heads-up notification, not full screen | Android 14+: Google Play grants USE_FULL_SCREEN_INTENT by default only to calling and alarm apps — it still rings, with Answer and Decline |
| A call stops soon after the screen locks | Some phones' battery savers stop background work: ask the customer to allow the app in battery settings. The SDK runs the call in a microphone foreground service, which Android itself keeps |
| Calls never use WebRTC | ai.graine:inapp-android-rtc is not in the app (or not the SDK's version), callTransport is WEBSOCKET, or the agent's In-app call audio is Standard (transportFallbackReason dashboard). With WEBRTC and no module, the reason is unavailable |

