# Android

> Add the Giov chat to a native Android app with Jetpack Compose, with verified identity, attachments, unread counts and push notifications.

The Android SDK shows the same conversation your website visitors have as a native screen. It ships as two artifacts, both version 0.1:

- `app.giovchat:giovchat-compose` is the Jetpack Compose chat screen, `GiovChat(...)`. It brings `giovchat-core` with it. Use this for a ready-made chat.
- `app.giovchat:giovchat-core` is the engine on its own: configuration, sessions, the realtime connection, the outbox, attachments, unread counts and push registration, with no UI and no Android dependency. Use it only if you draw the chat yourself.

Colours, greeting, pre-chat fields, GiovBot's name and avatar, attachment rules and branding come from **Settings → Chat widget** in the dashboard. In code, `tenantKey` is your workspace ID, shown in **Settings → Chat widget → Installation**.

## Install

Requires Android 7.0 (API 24) or newer. The library is compiled against API 35 and uses Java 17 bytecode, so set `compileOptions` and `jvmTarget` to 17. You need Kotlin 2.1 or newer and, for the Compose screen, the Kotlin Compose compiler plugin (`org.jetbrains.kotlin.plugin.compose`).

The SDK is on Maven Central. Make sure `mavenCentral()` is in your repositories:

```kotlin title="settings.gradle.kts"
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
```

Then add the artifact you need:

```kotlin title="build.gradle.kts" tab="Compose"
dependencies {
    implementation("app.giovchat:giovchat-compose:0.1.0")
}
```

```kotlin title="build.gradle.kts" tab="Core only"
dependencies {
    // Only the controller, for your own UI.
    implementation("app.giovchat:giovchat-core:0.1.0")
}
```

The library declares the `INTERNET` permission. `giovchat-core` alone runs on any JVM 17 or newer.

## Show the chat

`GiovChat` fills the space it is given, so it works as a whole Activity, a Fragment's `ComposeView`, a navigation destination or a bottom sheet.

```kotlin title="ChatActivity.kt"
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import app.giovchat.compose.GiovChat

class ChatActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            GiovChat(
                tenantKey = "YOUR_WORKSPACE_ID",
                onClose = { finish() },
            )
        }
    }
}
```

It draws every phase: loading, the pre-chat form, the greeting, connecting, the active conversation, and an error with a "Try again" button. It also forwards the app's foreground and background changes to the controller and tells it whether the chat is on screen.

A conversation starts when the visitor sends their first message or submits the pre-chat form. Opening the screen never creates an empty conversation. A stored conversation is resumed first; a closed one is forgotten, and the next message starts a new one.

### Own the controller

When something else in your app needs the chat (push registration, sign-out, an unread badge, or a chat that keeps counting replies while the screen is closed), create the controller yourself and pass it in:

```kotlin title="Chat.kt"
val controller = rememberGiovChatController(tenantKey = "YOUR_WORKSPACE_ID", keepConnected = true)

GiovChat(controller = controller, onClose = { /* ... */ })
```

- `rememberGiovChatController` keeps the controller in a ViewModel of the screen, so it survives rotation, and closes it when the screen goes away.
- Its arguments are compared with `equals`. Pass stable values, such as `remember { MyStore(context) }`, not a new instance on every recomposition. A new instance creates a new controller.
- Without Compose you can create `GiovChatController(tenantKey = ...)` in a ViewModel and call `close()` in `onCleared()`.
- `GiovChat(controller = ...)` never closes a controller you pass in. When it leaves the screen it marks the chat not visible, so a `keepConnected` controller keeps counting unread replies.

If the controller lives longer than the screen, start it yourself and forward the app lifecycle yourself:

```kotlin title="Chat.kt"
LaunchedEffect(controller) { controller.start() }
GiovLifecycleEffect(controller) // forwards onForeground / onBackground from ProcessLifecycleOwner
GiovChat(controller, autoStart = false, forwardLifecycle = false)
```

## Signed-in users

Pass the user's details through `contact`. Anything you provide also pre-fills the pre-chat form, which then asks only for the fields the workspace requires and you didn't provide.

```kotlin title="Chat.kt"
GiovChat(
    tenantKey = "YOUR_WORKSPACE_ID",
    contact = GiovContact(
        userId = account.id,
        userHash = account.giovUserHash, // computed on your server
        email = account.email,
        firstName = account.firstName,
    ),
    sessionKey = account.id,
)
```

`GiovContact` also takes `phone` and `lastName`.

- `userHash` is the HMAC-SHA256 (lowercase hex) of `userId`, keyed with the identity secret from **Settings → Chat widget → Visitor details**. Compute it on your server and send it to the app after sign-in; never ship the secret. See [Identify visitors](/docs/widget/identify-visitors) for server examples.
- Without a matching `userHash`, `userId` is treated like any other unverified detail and doesn't link the chat to an existing contact.
- `sessionKey` separates stored conversations of the same visitor, for example per account.
- **Sign-out and account switching:** call `controller.reset()`, or change `sessionKey`. `reset()` forgets the conversation, the stored session and the queued messages, and the next user starts fresh. If you registered a push token, unregister it first, so the device stops receiving the previous user's replies:

```kotlin title="SignOut.kt"
scope.launch {
    controller.unregisterPushToken(fcmToken) // works while the chat is closed
    controller.reset()
}
```

### Keep the conversation across launches

By default the session lives in memory for the life of the process, so the conversation is forgotten when the app is killed. Pass a `sessionStore` to resume it next time:

```kotlin title="Chat.kt"
val context = LocalContext.current
val store = remember { EncryptedSessionStore(context) }
GiovChat(tenantKey = "YOUR_WORKSPACE_ID", sessionStore = store)
```

Implement `GiovSessionStore` (`read`, `write` and `delete`, all `suspend`, keyed by a scope string) over encrypted storage. Encrypt with a key from the Android Keystore; no library is needed.

```kotlin title="EncryptedSessionStore.kt"
class EncryptedSessionStore(context: Context) : GiovSessionStore {
    private val prefs = context.applicationContext.getSharedPreferences("giov_chat_sessions", Context.MODE_PRIVATE)

    override suspend fun read(scope: String): GiovSession? = withContext(Dispatchers.IO) {
        val name = entry(scope)
        val stored = prefs.getString(name, null) ?: return@withContext null
        val parts = try {
            decrypt(stored).split('\n', limit = 2)
        } catch (_: Exception) {
            prefs.edit().remove(name).apply() // Written under a key this device no longer has.
            return@withContext null
        }
        if (parts.size == 2) GiovSession(parts[0], parts[1]) else null
    }

    override suspend fun write(scope: String, session: GiovSession) = withContext(Dispatchers.IO) {
        prefs.edit().putString(entry(scope), encrypt("${session.conversationId}\n${session.sessionToken}")).apply()
    }

    override suspend fun delete(scope: String) = withContext(Dispatchers.IO) {
        prefs.edit().remove(entry(scope)).apply()
    }

    // Only a hash of the scope is written to disk.
    private fun entry(scope: String): String =
        MessageDigest.getInstance("SHA-256").digest(scope.toByteArray()).joinToString("") { "%02x".format(it) }

    private fun encrypt(text: String): String {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key()) }
        return Base64.encodeToString(cipher.iv + cipher.doFinal(text.toByteArray()), Base64.NO_WRAP)
    }

    private fun decrypt(value: String): String {
        val bytes = Base64.decode(value, Base64.NO_WRAP)
        val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
            init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes, 0, 12))
        }
        return String(cipher.doFinal(bytes, 12, bytes.size - 12))
    }

    private fun key(): SecretKey {
        val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
        (keyStore.getKey("giov_chat_sessions", null) as? SecretKey)?.let { return it }
        val spec = KeyGenParameterSpec.Builder("giov_chat_sessions", KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            .build()
        return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { init(spec) }.generateKey()
    }
}
```

Jetpack DataStore with the same Keystore encryption works too.

> [!WARNING]
> The session token is a private credential. Keep it encrypted, never log it, and never share it between users.

Keep the preferences file out of Auto Backup and device transfer. The token is a bearer secret, and a restored copy couldn't be decrypted anyway, because the Keystore key never leaves the device. Point your manifest's `android:dataExtractionRules` and `android:fullBackupContent` at files that exclude it:

```xml title="res/xml/data_extraction_rules.xml" tab="Android 12+"
<data-extraction-rules>
    <cloud-backup>
        <exclude domain="sharedpref" path="giov_chat_sessions.xml" />
    </cloud-backup>
    <device-transfer>
        <exclude domain="sharedpref" path="giov_chat_sessions.xml" />
    </device-transfer>
</data-extraction-rules>
```

```xml title="res/xml/backup_rules.xml" tab="Android 11 and older"
<full-backup-content>
    <exclude domain="sharedpref" path="giov_chat_sessions.xml" />
</full-backup-content>
```

The store is keyed by API, workspace, `userId` and `sessionKey`, not by name, email or phone. A visitor who corrects their details keeps their conversation, and signing in as someone else never resumes the previous person's. Storage failures are reported through `onError` as `GiovStorageException` and never crash the chat.

## Attachments

Attachments appear once the conversation has started, when you pass an `attachmentPicker` and the published GiovBot agent allows them. Without a picker the attach button is hidden.

The SDK calls your picker with a `GiovPickerRequest` (`allowedTypes`, `maxFiles` and `maxSizeMb`), checks what comes back against the agent's policy, shows problems as a sentence and the files as removable chips, and uploads with progress when the visitor sends. Files must be a supported type (images, PDF, text and CSV, Office documents, audio), within the agent's size limit (at most 10 MB), and at most 5 per message.

The picker is a suspend lambda, so any ActivityResult contract fits. Return an empty list when the visitor cancels:

```kotlin title="AttachmentPicker.kt"
@Composable
fun rememberAttachmentPicker(): GiovAttachmentPicker {
    val context = LocalContext.current
    val pending = remember { arrayOfNulls<CompletableDeferred<List<Uri>>>(1) }
    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
        pending[0]?.complete(uris)
    }
    return remember(launcher, context) {
        val picker: GiovAttachmentPicker = { request ->
            val result = CompletableDeferred<List<Uri>>().also { pending[0] = it }
            launcher.launch(request.allowedTypes.toTypedArray())
            val uris = result.await().take(request.maxFiles)
            withContext(Dispatchers.IO) { uris.mapNotNull { describe(context, it) } }
        }
        picker
    }
}
```

`describe` is your own function that turns a `Uri` into a `GiovPickedFile`: `name`, `mimeType`, `size`, optional image `width` and `height`, an optional `thumbnail`, and `open`, which returns the file's `InputStream`. The bytes are read only when the upload starts, and again if an upload is retried. For bytes already in memory, use `GiovPickedFile.bytes(...)`.

Then pass it: `GiovChat(tenantKey = "YOUR_WORKSPACE_ID", attachmentPicker = rememberAttachmentPicker())`.

Messages show an image box of its final shape immediately, a blurred placeholder while the picture loads, and non-image files as tappable rows. To give the placeholder to other viewers too, pass a `thumbnail`: `"data:image/jpeg;base64,..."`, about 32 px, under 6,000 characters. `GiovImagePlaceholder.create(bitmap)` makes one for you.

## Unread replies and badges

A controller created with `keepConnected = true` stays connected while the chat is closed and the app is in the foreground, and counts replies the visitor hasn't seen. It only resumes an existing conversation and never starts one in the background. Read the count anywhere with `GiovUnread`:

```kotlin title="HomeScreen.kt"
val unread by GiovUnread.watch().collectAsState()                        // all chats
// val unread by GiovUnread.watch("YOUR_WORKSPACE_ID").collectAsState()  // one workspace
if (unread > 0) Badge(unread)
```

`GiovUnread.read()` returns the current number once. You can also pass `onUnreadCountChange` to `GiovChat` or `rememberGiovChatController`. Opening the chat (`visible = true`) clears the count.

## Push notifications

When the chat is closed and the app is in the background, replies arrive as push notifications.

1. Add your Firebase service account in **Settings → Chat widget → Push notifications**. Notifications are sent from your Firebase project, with your keys. The SDK doesn't include Firebase. See [Push credentials](/docs/mobile#push-credentials).
2. Add Firebase Messaging to your app and register the FCM token with the controller:

```kotlin title="Push.kt"
// When the controller is created or the user signs in
scope.launch {
    val token = FirebaseMessaging.getInstance().token.await()
    controller.registerPushToken(token) // never throws; returns a GiovPushResult
}

// In your FirebaseMessagingService
override fun onNewToken(token: String) {
    appScope.launch { controller.registerPushToken(token) }
}
```

- `registerPushToken` takes the raw FCM token. `FCM` is the default `platform`.
- It registers straight away when the conversation is connected, otherwise as soon as it connects. Call it at start-up and again whenever Firebase issues a new token. Launch it rather than awaiting it on a path that must finish, because before a conversation exists it waits for the visitor's first one.
- The result is a `GiovPushResult`. `registered` is true on success; otherwise `failure` is `UNSUPPORTED`, `NO_CONVERSATION` or `FAILED`.
- The last registered token is registered again automatically for the same visitor's next conversation. `reset()` forgets it.
- `unregisterPushToken(token)` stops pushes on this device, for sign-out. It works while the chat is closed, so call it before `reset()`.

A tapped notification carries a data payload you can use to open the right screen:

```
data = { type: "giov.message", tenantId, conversationId, messageId }
```

For data-only messages, show the notification yourself in `onMessageReceived` when `data["type"] == "giov.message"`, and open the activity that hosts `GiovChat` from its `PendingIntent`. Opening the chat catches up from history.

## Language

The interface follows the device language: English, French, Spanish, Portuguese or German, with English for others. Text you write in the dashboard (greeting, placeholder, bot name) is shown as written.

```kotlin title="Chat.kt"
GiovChat(
    tenantKey = "YOUR_WORKSPACE_ID",
    locale = "pt-BR",
    strings = { it.withOverrides(sendMessage = "Enviar", composerPlaceholder = "Escreva aqui…") },
)
```

`withOverrides` replaces individual strings on top of the language. `locale` also sets the language of push notification text.

## Appearance

- **Theme:** the dashboard setting (light, dark, auto) applies, and auto follows the device. Override it with `theme = GiovThemeMode.LIGHT`, `DARK` or `AUTO`.
- **Colour:** the dashboard's main colour applies. Override it with `primaryColor`, a Compose `Color`. Text on the brand colour is black or white, whichever reads better.
- **Fonts and surface colours:** `styleOverrides`.

```kotlin title="Chat.kt"
GiovChat(
    tenantKey = "YOUR_WORKSPACE_ID",
    theme = GiovThemeMode.DARK,
    primaryColor = Color(0xFF0EA5E9),
    styleOverrides = GiovChatStyleOverrides(
        fontFamily = myFonts, // Load this font in your app first.
        container = Color(0xFFF8FAFC),
        header = Color(0xFF0F172A),
        headerTitle = GiovTextOverride(color = Color.White, fontWeight = FontWeight.ExtraBold),
        agentBubble = Color(0xFFF1F5F9),
        visitorBubble = Color(0xFF0EA5E9),
        button = Color(0xFF0F172A),
    ),
)
```

| Slots | Type | Applies to |
|---|---|---|
| `container`, `header`, `composer`, `form` | `Color` | Chat surface, header, composer row, pre-chat form |
| `agentBubble`, `visitorBubble` | `Color` | Agent, greeting and typing bubbles; visitor bubbles |
| `button` | `Color` | Send, start-chat and retry buttons |
| `inputBackground` | `Color` | Composer and pre-chat input background |
| `headerTitle`, `headerSubtitle` | `GiovTextOverride` | Header text |
| `agentText`, `visitorText` | `GiovTextOverride` | Message content, sender labels and timestamps; agent text also covers greeting and typing |
| `formTitle`, `label`, `buttonText`, `hint`, `errorText` | `GiovTextOverride` | Form heading and labels, button text, loading hint, attachment chips and input placeholders, errors |
| `input` | `GiovTextOverride` | Text of the composer and all pre-chat inputs |

`GiovTextOverride` takes `fontFamily`, `fontWeight`, `fontStyle` and `color`. A slot's `fontFamily` takes precedence over the top-level one. Layout, sizes, spacing and the branding footer stay under the SDK's control.

**Branding:** Starter and Pro always show **Powered by GiovChat**. Business and Advanced can turn it off in **Settings → Chat widget → Appearance**. The footer's look isn't affected by style overrides.

Other parameters worth knowing:

- `safeArea` keeps content clear of the status bar, cutouts, keyboard and navigation bar. Turn it off if a parent already applies the insets.
- `onOpenUrl` changes how links and attachments open; the default is `ACTION_VIEW`. Only http(s) and mailto links reach it.
- `imageLoader` loads avatars and image attachments. The default is a small OkHttp loader with a memory cache. Implement `GiovImageLoader` to use Coil or Glide.

For more on dashboard-side settings, see [Customize the widget](/docs/widget/customize).

## Lifecycle and behaviour

- **Rotation and other configuration changes** keep the connection, the unsent messages and the conversation, because the controller lives in a ViewModel.
- **Background:** the socket closes 5 seconds after the app goes to the background, so the server can send push notifications instead. Change this with `backgroundGraceMs` on `GiovChatController`. On return to the foreground the chat reconnects or reloads history, so nothing sent meanwhile is missed.
- **Sending:** a message shows at once as "Sending…" and is queued, so text typed while reconnecting isn't lost. One that can't be delivered shows "Not sent", and the visitor can tap to retry or discard it.
- **Reconnecting:** a first connection that fails is retried automatically with backoff. While a socket comes back, the chat shows "Connection lost. Reconnecting…". Calling `start()` on a started controller does nothing; `retry()` restarts.
- **Streaming:** GiovBot replies stream in as a draft that is replaced in place by the saved reply.
- **Links:** only `http(s)` and `mailto:` links are tappable.
- **Accessibility:** tap targets are at least 48 dp, controls have content descriptions in the chosen language, and text follows the system font size (up to 2x). The typing dots stand still when animations are turned off.

## Errors

Failures are typed. Pass `onError` to receive them; each is a subclass of `GiovChatException`, which has `message`, `status` and `code`. The chat shows a translated sentence to the visitor, never the server's code.

| Exception | Meaning |
|---|---|
| `GiovNetworkException` | The network couldn't be reached, or a request timed out |
| `GiovSessionExpiredException` | The stored session is gone. The SDK forgets it and the next message starts a new conversation |
| `GiovConversationClosedException` | The team closed the conversation. The next message starts a new one |
| `GiovRateLimitedException` | Too many requests; wait `retryAfterSeconds` |
| `GiovWorkspaceBusyException` | The workspace is at its plan's limit of open conversations; `retryAfterSeconds` |
| `GiovIdentityRejectedException` | The `userHash` is wrong, and the workspace requires a signed identity |
| `GiovAttachmentRejectedException` | An upload was refused (type, size or the agent's policy) |
| `GiovSendFailedException` | A message couldn't be delivered |
| `GiovInvalidResponseException` | The server answered something the protocol doesn't allow |
| `GiovDisconnectedException` | The chat was closed while a call was in flight |
| `GiovStorageException` | Your `GiovSessionStore` failed to read, write or delete |
| `GiovServerException` | Any other refusal, such as an unknown workspace ID or a server error |

## Parameters

`GiovChat(tenantKey = ...)` creates and owns the controller. `GiovChat(controller = ...)` uses one you own.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `tenantKey` | `String` | **required** | Your workspace ID |
| `controller` | `GiovChatController` | **required** (controller overload) | The controller to draw |
| `modifier` | `Modifier` | `Modifier` | Layout; the chat fills the space it gets |
| `visible` | `Boolean` | `true` | Whether the chat is on screen. A hidden chat disconnects unless `keepConnected`; showing it clears unread replies |
| `onClose` | `(() -> Unit)?` | `null` | Shows a close button in the header when set |
| `contact` | `GiovContact?` | `null` | Signed-in user's details; also pre-fills the pre-chat form |
| `initialContact` | `GiovContact?` | `null` | Pre-fills the pre-chat form (controller overload) |
| `sessionKey` | `String?` | `null` | Isolates stored conversations, for example per account |
| `sessionStore` | `GiovSessionStore?` | In memory, for the process | Your encrypted storage for the session |
| `keepConnected` | `Boolean` | `false` | Stay connected while hidden to count unread replies |
| `baseUrl` | `String` | `https://api.giovchat.app` | API base URL override |
| `theme` | `GiovThemeMode?` | From dashboard | `LIGHT`, `DARK` or `AUTO`; `AUTO` follows the device |
| `primaryColor` | `Color?` | From dashboard | Brand colour override |
| `styleOverrides` | `GiovChatStyleOverrides` | none | Allowed font and colour overrides |
| `locale` | `String?` | Device language | Interface language |
| `strings` | `((GiovChatStrings) -> GiovChatStrings)?` | `null` | Replace interface strings with `withOverrides` |
| `attachmentPicker` | `GiovAttachmentPicker?` | `null` | Enables attachments, when the agent allows them |
| `onOpenUrl` | `((String) -> Unit)?` | `ACTION_VIEW` | How links and attachments open |
| `imageLoader` | `GiovImageLoader` | OkHttp loader | Loads avatars and image attachments |
| `safeArea` | `Boolean` | `true` | Keep content clear of system bars and the keyboard |
| `lifecycleOwner` | `LifecycleOwner` | `ProcessLifecycleOwner` | Whose start and stop mean foreground and background |
| `autoStart` | `Boolean` | `true` | Start the controller when the screen appears (controller overload) |
| `forwardLifecycle` | `Boolean` | `true` | Forward foreground and background changes (controller overload) |
| `onError` | `((GiovChatException) -> Unit)?` | `null` | Every failure, typed |
| `onConversationStart` | `((String) -> Unit)?` | `null` | Called with the conversation ID |
| `onConnectionStatusChange` | `((GiovConnectionStatus) -> Unit)?` | `null` | `CONNECTING`, `CONNECTED` or `DISCONNECTED` |
| `onUnreadCountChange` | `((Int) -> Unit)?` | `null` | Unread replies while hidden |

`GiovChat(tenantKey = ...)` and `rememberGiovChatController` also take `http` and `socketFactory`, for tests and proxies.

The controller has these methods. Without Compose, collect `controller.state` (a `StateFlow<GiovChatUiState>`) and call them yourself.

| Method | Description |
|---|---|
| `start()` | Loads the configuration and resumes a stored conversation. Does nothing if already started |
| `retry()` | Starts over after an error, or forces a fresh connection |
| `reset()` | Forgets the conversation, stored session and outbox (sign-out, "new chat") |
| `send(text, files)` | Sends a message, with optional `GiovPickedFile`s |
| `submitContact(info)` | Submits the pre-chat form and starts the conversation |
| `retryMessage(clientMessageId)` | Retries a message that failed to send |
| `discardMessage(clientMessageId)` | Removes a message that failed to send |
| `notifyTyping()` | Tells the team the visitor is typing |
| `checkFiles(files, alreadyAttached)` | Checks files against the attachment policy |
| `registerPushToken(token, platform, ...)` | Registers an FCM token; never throws |
| `unregisterPushToken(token)` | Stops pushes on this device; returns true when the server confirmed |
| `setVisible(value)` | Shows or hides the chat |
| `close()` | Stops the controller and releases its connection. Unsent messages are lost |

The source and a runnable sample app are in the public repository, [Fegig-Tech/giovchat-android](https://github.com/Fegig-Tech/giovchat-android).
