Skip to content

Android

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

On this page

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:

settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

Then add the artifact you need:

build.gradle.kts
dependencies {
    implementation("app.giovchat:giovchat-compose: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.

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:

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:

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.

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 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:
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:

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.

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:

res/xml/data_extraction_rules.xml
<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>

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:

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:

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.
  2. Add Firebase Messaging to your app and register the FCM token with the controller:
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:

Code
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.

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.
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),
    ),
)
SlotsTypeApplies to
container, header, composer, formColorChat surface, header, composer row, pre-chat form
agentBubble, visitorBubbleColorAgent, greeting and typing bubbles; visitor bubbles
buttonColorSend, start-chat and retry buttons
inputBackgroundColorComposer and pre-chat input background
headerTitle, headerSubtitleGiovTextOverrideHeader text
agentText, visitorTextGiovTextOverrideMessage content, sender labels and timestamps; agent text also covers greeting and typing
formTitle, label, buttonText, hint, errorTextGiovTextOverrideForm heading and labels, button text, loading hint, attachment chips and input placeholders, errors
inputGiovTextOverrideText 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.

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.

ExceptionMeaning
GiovNetworkExceptionThe network couldn't be reached, or a request timed out
GiovSessionExpiredExceptionThe stored session is gone. The SDK forgets it and the next message starts a new conversation
GiovConversationClosedExceptionThe team closed the conversation. The next message starts a new one
GiovRateLimitedExceptionToo many requests; wait retryAfterSeconds
GiovWorkspaceBusyExceptionThe workspace is at its plan's limit of open conversations; retryAfterSeconds
GiovIdentityRejectedExceptionThe userHash is wrong, and the workspace requires a signed identity
GiovAttachmentRejectedExceptionAn upload was refused (type, size or the agent's policy)
GiovSendFailedExceptionA message couldn't be delivered
GiovInvalidResponseExceptionThe server answered something the protocol doesn't allow
GiovDisconnectedExceptionThe chat was closed while a call was in flight
GiovStorageExceptionYour GiovSessionStore failed to read, write or delete
GiovServerExceptionAny 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.

ParameterTypeDefaultDescription
tenantKeyStringrequiredYour workspace ID
controllerGiovChatControllerrequired (controller overload)The controller to draw
modifierModifierModifierLayout; the chat fills the space it gets
visibleBooleantrueWhether the chat is on screen. A hidden chat disconnects unless keepConnected; showing it clears unread replies
onClose(() -> Unit)?nullShows a close button in the header when set
contactGiovContact?nullSigned-in user's details; also pre-fills the pre-chat form
initialContactGiovContact?nullPre-fills the pre-chat form (controller overload)
sessionKeyString?nullIsolates stored conversations, for example per account
sessionStoreGiovSessionStore?In memory, for the processYour encrypted storage for the session
keepConnectedBooleanfalseStay connected while hidden to count unread replies
baseUrlStringhttps://api.giovchat.appAPI base URL override
themeGiovThemeMode?From dashboardLIGHT, DARK or AUTO; AUTO follows the device
primaryColorColor?From dashboardBrand colour override
styleOverridesGiovChatStyleOverridesnoneAllowed font and colour overrides
localeString?Device languageInterface language
strings((GiovChatStrings) -> GiovChatStrings)?nullReplace interface strings with withOverrides
attachmentPickerGiovAttachmentPicker?nullEnables attachments, when the agent allows them
onOpenUrl((String) -> Unit)?ACTION_VIEWHow links and attachments open
imageLoaderGiovImageLoaderOkHttp loaderLoads avatars and image attachments
safeAreaBooleantrueKeep content clear of system bars and the keyboard
lifecycleOwnerLifecycleOwnerProcessLifecycleOwnerWhose start and stop mean foreground and background
autoStartBooleantrueStart the controller when the screen appears (controller overload)
forwardLifecycleBooleantrueForward foreground and background changes (controller overload)
onError((GiovChatException) -> Unit)?nullEvery failure, typed
onConversationStart((String) -> Unit)?nullCalled with the conversation ID
onConnectionStatusChange((GiovConnectionStatus) -> Unit)?nullCONNECTING, CONNECTED or DISCONNECTED
onUnreadCountChange((Int) -> Unit)?nullUnread 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.

MethodDescription
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 GiovPickedFiles
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.

Loading…