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-composeis the Jetpack Compose chat screen,GiovChat(...). It bringsgiovchat-corewith it. Use this for a ready-made chat.app.giovchat:giovchat-coreis 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:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Then add the artifact you need:
dependencies {
implementation("app.giovchat:giovchat-compose:0.1.0")
}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.
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:
val controller = rememberGiovChatController(tenantKey = "YOUR_WORKSPACE_ID", keepConnected = true)
GiovChat(controller = controller, onClose = { /* ... */ })rememberGiovChatControllerkeeps 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 asremember { 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 callclose()inonCleared(). GiovChat(controller = ...)never closes a controller you pass in. When it leaves the screen it marks the chat not visible, so akeepConnectedcontroller keeps counting unread replies.
If the controller lives longer than the screen, start it yourself and forward the app lifecycle yourself:
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.
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.
userHashis the HMAC-SHA256 (lowercase hex) ofuserId, 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,userIdis treated like any other unverified detail and doesn't link the chat to an existing contact. sessionKeyseparates stored conversations of the same visitor, for example per account.- Sign-out and account switching: call
controller.reset(), or changesessionKey.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:
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:
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.
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.
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:
<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><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:
@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:
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.
- 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.
- Add Firebase Messaging to your app and register the FCM token with the controller:
// 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) }
}registerPushTokentakes the raw FCM token.FCMis the defaultplatform.- 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.registeredis true on success; otherwisefailureisUNSUPPORTED,NO_CONVERSATIONorFAILED. - 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 beforereset().
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.
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,DARKorAUTO. - Colour: the dashboard's main colour applies. Override it with
primaryColor, a ComposeColor. Text on the brand colour is black or white, whichever reads better. - Fonts and surface colours:
styleOverrides.
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:
safeAreakeeps content clear of the status bar, cutouts, keyboard and navigation bar. Turn it off if a parent already applies the insets.onOpenUrlchanges how links and attachments open; the default isACTION_VIEW. Only http(s) and mailto links reach it.imageLoaderloads avatars and image attachments. The default is a small OkHttp loader with a memory cache. ImplementGiovImageLoaderto 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
backgroundGraceMsonGiovChatController. 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)andmailto: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 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.