Graine AI

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

// settings.gradle.kts
dependencyResolutionManagement {
    repositories { google(); mavenCentral() }
}
 
// app/build.gradle.kts
dependencies {
    implementation("ai.graine:inapp-android:0.10.3")
}

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:

dependencies {
    implementation("ai.graine:inapp-android:0.10.3")
    implementation("ai.graine:inapp-android-rtc:0.10.3")   // the same version as the SDK
}

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. Add graine-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

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        val graine = GraineInApp.init(this, GraineConfig(
            baseUrl = "https://www.graine.ai",
            publishableKey = "pk_live_…",          // Agent → Embed & Widgets
            appVersion = BuildConfig.VERSION_NAME,
            debug = BuildConfig.DEBUG,             // bridge traffic and the engine's console in logcat
        ))
        graine.registerAction("open_screen") { args ->
            // Main thread. Answer within six seconds, and say what actually happened.
            router.open(args.optString("path"))
            ActionResult.ok("Opened ${args.optString("path")}.")
        }
    }
}

init must run before any bar is created, which Application.onCreate guarantees.

2. Mount the engine and the bar

class KycActivity : Activity() {
    private val graine get() = GraineInApp.get()
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.kyc)
        graine.voice.attach(this)   // loads the engine idle: no microphone, nothing dialled
 
        val bar = GraineAgentBarView(this)
        findViewById<FrameLayout>(R.id.root).addView(bar,
            FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT, Gravity.BOTTOM))
    }
 
    override fun onRequestPermissionsResult(code: Int, permissions: Array<out String>, results: IntArray) {
        if (!graine.voice.onRequestPermissionsResult(code, results))
            super.onRequestPermissionsResult(code, permissions, results)
    }
}

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

graine.setScreen(GraineScreen(
    screen = "kyc_documents",
    title = "Upload your documents",
    journey = GraineScreen.Journey("KYC", step = 2, of = 4, next = "Selfie"),
    fields = listOf(GraineField(
        name = "pan", label = "PAN", value = pan.text.toString(),
        status = if (panValid) GraineField.Status.FILLED else GraineField.Status.INVALID,
        error = panError,
    )),
))

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:

Box(Modifier.fillMaxSize()) {
    AppContent()
    AndroidView(
        factory = { GraineAgentBarView(it) },
        modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
    )
}

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.

val calls = GraineInApp.get().calls
calls.configure(GraineCallsConfig(displayName = "Acme Assistant", smallIcon = R.drawable.ic_call))
calls.addListener(object : GraineCallListener {
    override fun onCallOutcome(callId: String, outcome: GraineCallOutcome) = myServer.report(callId, outcome.wire)
})
 
// A "Call us" button
calls.startCall(activity, reason = "kyc_help")

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:

override fun onNewToken(token: String) = GraineCalls.onNewToken(this, token)
override fun onMessageReceived(message: RemoteMessage) {
    if (GraineCalls.handlePush(this, message.data)) return
    // …your own messages
}

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:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" tools:node="remove" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" />
    <application>
        <service android:name="ai.graine.inapp.GraineCallService" tools:node="remove" />
    </application>
</manifest>

Reference

GraineConfig

OptionDefault
baseUrl—https://www.graine.ai
publishableKey—Selects the agent. Public by design: it cannot read data or place a phone call
variablesemptyPrompt variables known at build time
transportthe dashboard'sForce Transport.WEBRTC or Transport.WEBSOCKET, for testing
appVersion—Lands on the call record, to tell builds apart
endOnBackgroundtrueEnd a call two seconds after the app leaves the foreground
timingLoad watchdog and retry ladder: stallMs, bootMs, hardCapMs, retryDelaysMs, endWatchdogMs
friction—Your layer of the stuck-customer rules, over the dashboard's
debugfalseEvery bridge message and the engine's console in logcat; the engine inspectable in chrome://inspect
chatSizethe dashboard'sHow 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 / removeListenerEvents, below
answerWidget(widgetId, data, summary)Answer a component from a UI of your own, on the conversation that is live. 0.9.0
voiceThe in-app engine's call — the bar
callsNative 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, readyCall state
qualityEXCELLENT, GOOD, POOR, LOST or UNKNOWN
online, reconnecting, connectivityWhat 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
phaseidle, connecting, reconnecting, thinking, speaking, listening — one thing to draw from. 0.3.0
errorSet when the call cannot work until something is fixed. 0.3.0
connectStage, slowConnect, callFailedWhere 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
stateIDLE, RINGING, CONNECTING, ACTIVE, ENDED
callThe call in progress: callId, direction, callerName, reason, variables
muted, speaker, agentSpeaking, caption, connectedAtWhat to draw. caption is (who, text, still being said); connectedAt is SystemClock.elapsedRealtime() milliseconds
transport, transportFallbackReasonWhat 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 / removeListenerGraineCallListener

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, radiusBrand colour, heading, face, light / dark / auto, corner rounding
draggableThe customer can drag the bar up and down; a tap still taps. Default true
placementOffsetRestore a position you saved
onPlacementChangeThe customer let go somewhere new: save it. The SDK stores nothing
keyboardAwareRise above the soft keyboard. Default true
widgetViewThe components drawn on the bar, which move with it
onCloseThe 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

SymptomCheck
Nothing happens, onError says 403app:// is missing from the agent's Allowed domains
IllegalStateException: GraineInApp.init … has not been calledA bar was created before Application.onCreate ran init
The call ends when the customer moves to another activitySomething calls voice.detach() in onDestroy. Remove it
The agent does not know the screensetScreen 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 closedSend 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 screenAndroid 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 locksSome 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 WebRTCai.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

Next steps