Android SDK dev.hatang:chat
dev.hatang:chat adds a native support chat to your Android app. It talks the realtime protocol to the Ha Tang gateway directly (no web view). The UI is written in Jetpack Compose, and apps built with Views and XML open it with one line, HatangChat.open(context). The SDK does not depend on Firebase: your app gets the FCM token and passes it to the SDK. Customers who write from your app land in the same inbox as visitors from your website, Zalo, Messenger, Instagram and TikTok Shop.
dev.hatang:chat 0.4.1 · protocol v1 · Android 8.0 (API 26)+ · Kotlin 1.9+ · Jetpack Compose (BOM 2024.12+) · Maven Central · Changelog · API reference (Dokka)
Version 0.3 includes the chat screen, the visitor's conversation list, a floating launcher, signed identity, photo and file attachments, product, order and payment cards, push notifications through your own Firebase project and session-restoring deep links. The API may still change before 1.0.
Installation
Get an app key from the dashboard
The SDK needs two values: the Site ID and an app key (hak_…). The app key replaces the browser's Origin check: the app sends it when it starts, so the gateway knows which app a session comes from.
- In the dashboard, open Support → Channels → Add channel → Mobile app.
- Click Create app key, give it a name (for example "Moc Huong Android") and pick the Android platform. The package name is optional and only a reminder.
- The key is shown once. Pass it to
HatangConfig(appKey = …).
App keys are public in the same way the site ID is (they ship inside your APK), but you can revoke them. To rotate a key safely, create a new one, ship an update that uses it, then revoke the old key. Sessions that use a revoked key are cut off within a minute.
Add the dependency
The library is on Maven Central, which new Android projects already list in settings.gradle.kts.
dependencies {
implementation("dev.hatang:chat:0.4.1")
}
There are two artifacts:
| Artifact | Contains |
|---|---|
dev.hatang:chat | The HatangChat facade, the Compose UI, secure storage, lifecycle handling and attachments. Use this one. |
dev.hatang:chat-core | Pure Kotlin/JVM: protocol types, REST client, WebSocket and the ChatEngine (as a StateFlow). Pulled in by chat; use it alone only to build your own UI. |
The SDK uses common libraries (OkHttp, kotlinx.serialization, coroutines, Compose Material 3, Coil, DataStore, Tink, and the ZXing core encoder for QR codes). If your app already depends on them, Gradle picks the higher version.
Attachments and permissions
The composer's attach button offers the photo picker (PickVisualMedia, images only) and files (OpenDocument). Photos in HEIC/HEIF are converted to JPEG and scaled down to 2048 px before upload; files can be up to 10 MB. Both pickers are system UIs and need no extra permissions. The library's manifest adds only INTERNET and ACCESS_NETWORK_STATE, and declares its own HatangChatActivity (with adjustResize, so the composer stays above the keyboard).
Initialization
Call HatangChat.configure once in your Application.onCreate. With autoConnect (the default), the SDK bootstraps the visitor, stores the token and opens the socket right away.
import android.app.Application
import dev.hatang.chat.HatangChat
import dev.hatang.chat.HatangConfig
import dev.hatang.chat.core.protocol.HatangLocale
import dev.hatang.chat.platform.logcatLogger
class App : Application() {
override fun onCreate() {
super.onCreate()
HatangChat.configure(
this,
HatangConfig(
siteId = "site_…",
appKey = "hak_…",
locale = HatangLocale.VI,
logger = logcatLogger(), // while integrating; the default is HatangLogger.SILENT
),
)
}
}
HatangConfig field | Default | Meaning |
|---|---|---|
siteId, appKey | required | From the website's Apps tab. |
baseUrl | HatangChat.DEFAULT_BASE_URL (https://chat.hatang.dev) | The gateway. Change it only for a local gateway (see Troubleshooting). |
locale | null | HatangLocale.VI or HatangLocale.EN. Without it: the visitor's stored choice, then the site's default, then the device. |
themeMode, theme, darkTheme | SITE, null, null | See Theming. |
strings | null | Wording overrides per locale; see Languages. |
autoConnect | true | false to call HatangChat.connect() yourself later (for example after a consent screen). |
openOnProactiveMessage | true | Open the chat when the team starts a conversation that asks to open on the visitor's device. |
logger | HatangLogger.SILENT | logcatLogger() writes socket frames and HTTP calls to Logcat. |
appInfo | read from the package manager and Build | Overrides the app and device details sent to the gateway. |
commerce | HatangCommerce.STANDARD | How "Buy" and "Pay now" on chat cards behave; see Sales cards and in-app purchase. |
Calling configure again replaces the previous engine. Other members of the facade:
| Member | Meaning |
|---|---|
HatangChat.isConfigured | configure has run. |
HatangChat.connect() / disconnect() | Suspend functions: bootstrap and connect, or close the socket while staying configured. |
HatangChat.dispose() | Release the engine; call configure again to use the SDK. |
HatangChat.state | StateFlow<ChatState> with everything the UI renders (connection, unread, availability, locale…). |
HatangChat.unreadCount | StateFlow<Int> of messages received while the chat was not on screen. |
HatangChat.session | The current visitor id, conversation id, locale, and identity details. |
HatangChat.config, HatangChat.engine | The configuration and the underlying ChatEngine; both throw before configure. |
The SDK follows the process lifecycle on its own: when the app moves to the background it tells the gateway (app.state), so new replies go out as push notifications instead of over the socket.
Identify visitors
When the user signs in, call identify with the id from your own system. hash is the HMAC-SHA256 (hex) of userId with the site's identity secret. Compute it on your server and return it to the app; never put the secret in the app. A signed identity is merged with the same person's earlier sessions (on the web too) and shows as verified in the inbox; without a signature the details are stored as self-reported. See Identity verification.
import dev.hatang.chat.core.session.HatangUser
lifecycleScope.launch {
// Your backend returns { userId, hash } after sign-in
HatangChat.identify(
HatangUser(
userId = me.id,
email = me.email,
name = me.name,
phone = me.phone,
hash = me.hatangHash,
data = mapOf("plan" to "pro", "orders" to 12),
),
)
}
identify throws HatangException (with code, message and status) when the gateway refuses the call. Computing hash on your backend takes one line:
const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex");
$hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET'));
When the identity already belongs to another visitor (the user chatted on another device, for example), the gateway merges the sessions: the SDK receives a new token, reloads the conversation and emits HatangSessionChanged(reason = IDENTITY_MERGED). You do not need to do anything else.
Sign-out
lifecycleScope.launch {
HatangChat.clearPushToken() // optional: stop pushes to this device
HatangChat.reset()
}
reset() revokes the old token and starts a fresh visitor, so the next person on the same device does not see the previous user's conversations.
Open and embed the chat
Open the chat screen
From anywhere with a Context, including View-based apps:
HatangChat.open(context) // the active conversation (or a new one)
HatangChat.open(context, conversationId = "cnv_…") // a specific conversation
HatangChat.openInbox(context) // the visitor's conversation list
HatangChat.close()
open starts the SDK's own HatangChatActivity. HatangChat.openIntent(context, conversationId) returns the same Intent, for example to put in a PendingIntent. HatangChat.isOpen tells you whether a chat screen is showing.
Floating launcher
HatangChatLauncher is a Compose chat button with an unread badge. It uses the site's brand color and the icon, image and label configured under Launcher button in the site settings, and hides itself while the team is away when the site asks for that. It renders nothing before configure.
Box(Modifier.fillMaxSize()) {
HomeContent()
HatangChatLauncher(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
)
}
| Parameter | Default | Meaning |
|---|---|---|
modifier | Modifier | Position and padding. |
onClick | HatangChat.open(context) | What a tap does. |
showBadge | true | Show the unread count. |
engine | HatangChat.engine | Engine override (tests, several sites). |
icon | null | A composable that replaces the site's icon. |
Embed in your own navigation
The screens are ordinary composables, so you can place them in your own navigation graph or tab:
| Composable | Parameters |
|---|---|
HatangChatScreen | modifier, conversationId (default: the active one), onClose (shows a close button), title (default: the site name), engine, themeMode, theme, darkTheme, strings |
HatangConversationView | The conversation and composer without a top bar, for a tab of your own: modifier, engine, theme, darkTheme, strings |
HatangChatInbox | modifier, onOpenConversation, onNewConversation, onClose, showTopBar, engine |
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable("support") {
HatangChatScreen(onClose = { navController.popBackStack() })
}
composable("support/history") {
val context = LocalContext.current
HatangChatInbox(
onOpenConversation = { conv -> HatangChat.open(context, conv.id) },
onNewConversation = { HatangChat.open(context) },
)
}
}
HatangChatScreen draws edge to edge and pads itself for the status bar, navigation bar and keyboard.
HatangChat.showMessage("Need help choosing a size?", senderName = "Moc Huong") adds a local assistant bubble that is never sent to the gateway, for example a greeting that depends on the screen.
Sales cards and in-app purchase
Agents send product cards, order cards, payment requests, quick replies and rating requests from the inbox; the SDK draws them under the message. By default, Buy on a product card writes "I'd like to buy …" into the composer, and a payment card shows a VietQR code (drawn by the SDK from the payment data), the transfer note and a Pay now button that opens the web checkout.
- Physical goods and real-world services (tea, clothing, delivery, bookings…): keep the default. Google Play's payments policy does not allow Play Billing for these, and Play Billing cannot charge an arbitrary order total anyway.
- Digital goods and features used inside your app (memberships, coins, in-app courses…): these must be sold through Google Play Billing. Hand the purchase to your billing code and turn off external checkout:
HatangChat.configure(
this,
HatangConfig(
siteId = "site_…",
appKey = "hak_…",
commerce = HatangCommerce(
onBuyProduct = { context, product ->
// The Google Play product ID entered for this product in the dashboard;
// null: not an in-app product, keep the default behavior
product.googlePlayProductId?.let { id -> billing.launchPurchase(context, id); true } ?: false
},
showExternalCheckout = false, // no QR code, no transfer note, no web checkout
),
),
)
HatangCommerce field | Meaning |
|---|---|
onBuyProduct(context, product) | Buy on a product card. Return true when you handled it; false falls back to prefilling the composer. |
onPay(context, payment) | Pay now on a payment card. Return true when you handled it; false falls back to the web checkout. |
showExternalCheckout | Default true. false hides the VietQR code, the transfer note and the web checkout. |
Enter the store product IDs in the dashboard under Products → open a product → Prices on the App Store and Google Play. Product cards then carry googlePlayProductId and appStoreProductId. With showExternalCheckout = false and no onPay, a payment card shows only the amount and a note that the team will explain how to pay.
The whole flow, including store setup, reporting renewals and restores, importing existing subscribers and testing, is in In-app purchases and subscriptions.
Subscriptions. A customer in your Android app sees the Google Play price you set for the product; subscription cards also carry basePlanId, offerId, billingPeriod (P1M, P1Y…) and trialDays. So that Google Play's real-time notifications reach this customer in Hatang, pass their account id when launching the billing flow, then tell the team:
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(details))
.setObfuscatedAccountId(HatangChat.purchaseToken(currentUser.id)) // the id you sign in with (identify)
.build()
// after onPurchasesUpdated succeeds:
HatangChat.reportPurchase(storeProductId = "pro", purchaseToken = purchase.purchaseToken, productType = "subs") // "inapp" for one-time products
Hatang reads the purchase with your Play service account before recording it on the customer (a made-up token is refused) and acknowledges it, so you don't have to (Play refunds purchases left unacknowledged for three days). For one-time products pass productType = "inapp" and consumable = true or false; consume consumables in the app as usual. Purchases made before the customer signs in move to them when you call identify.
Then connect the app under Channels → your app → Subscriptions from the App Store and Google Play: the customer's plan, renewals, cancellations and refunds show on their profile and the AI assistant knows them.
A payment card switches to "Paid" as soon as the timeline holds an order_paid message or event for its order or payment, and then hides the QR code, transfer note and payment button.
Events and data
HatangChat.events is a Flow<HatangEvent> that follows re-configuration. Collect it in a lifecycle-aware scope:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
HatangChat.events.collect { event ->
when (event) {
is HatangReady -> Log.d("chat", "ready ${event.visitorId}")
is HatangMessageReceived -> showInAppBanner(event.message.content)
is HatangConversion -> analytics.log(event.kind.name, event.data)
is HatangProtocolOutdated -> promptUpdate()
else -> Unit
}
}
}
}
| Event | When |
|---|---|
HatangReady(visitorId, conversationId) | Bootstrap and authentication finished. |
HatangOpened, HatangClosed | The chat screen appeared or went away. |
HatangMessageReceived(message), HatangMessageSent(message) | A message from the team or bot; a visitor message was stored (also from another device). |
HatangConversationStatus(conversationId, status) | The conversation became open, pending, resolved or closed. |
HatangConversion(kind, data) | CHAT_STARTED, LEAD or PURCHASE (with value, currency, transaction_id in data). |
HatangSessionChanged(visitorId, reason) | IDENTITY_MERGED, RESET, RESTORED or TOKEN_REJECTED. |
HatangProtocolOutdated(reason) | The gateway no longer serves this SDK version; ask the user to update the app. |
For a badge, collect HatangChat.unreadCount:
val unread by HatangChat.unreadCount.collectAsStateWithLifecycle()
BadgedBox(badge = { if (unread > 0) Badge { Text("$unread") } }) {
Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Support")
}
Send context to the inbox:
lifecycleScope.launch {
HatangChat.setData(mapOf("cart_total" to 450000, "last_order" to "SO-1001")) // shown to agents, up to 8 KB
}
HatangChat.pushEvent("viewed_product", mapOf("sku" to "TS-100")) // the visitor's timeline
HatangChat.setScreen("ProductPage", mapOf("sku" to "TS-100")) // where the visitor is in your app
Push notifications
Ha Tang sends push notifications through your own Firebase project (FCM HTTP v1). Upload the Firebase service account to the website's Apps tab under Push notifications (Firebase); see Push notifications. Then add com.google.firebase:firebase-messaging and the google-services plugin to your app, and pass the token and the notification data to the SDK from your FirebaseMessagingService:
class AppMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
if (!HatangChat.isConfigured) return
CoroutineScope(Dispatchers.IO).launch { runCatching { HatangChat.setPushToken(token) } }
}
override fun onMessageReceived(message: RemoteMessage) {
if (!HatangChat.isHatangNotification(message.data)) return // not ours: your own handling
// Build a notification whose tap opens the conversation
val tap = PendingIntent.getActivity(
this, 0, HatangChat.openIntent(this, HatangChat.conversationIdOf(message.data)),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(this, "hatang_messages")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(message.notification?.title)
.setContentText(message.notification?.body)
.setAutoCancel(true)
.setContentIntent(tap)
.build()
NotificationManagerCompat.from(this).notify(message.messageId.hashCode(), notification)
}
}
Register the current token once at startup, after configure:
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
lifecycleScope.launch { HatangChat.setPushToken(token) }
}
When the app is in the background, Android shows the notification itself and a tap opens your launcher activity with the FCM data as intent extras. Pass them to the SDK:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent.extras?.let { extras ->
val data = extras.keySet().associateWith { extras.getString(it).orEmpty() }
if (HatangChat.isConfigured && savedInstanceState == null) HatangChat.handleNotification(this, data)
}
// setContent { … }
}
isHatangNotification(data)checks forhatang: "1"(also inside a nesteddataJSON string), so your own notifications and Ha Tang's can coexist.conversationIdOf(data)returns the conversation a notification points at.handleNotification(context, data)opens that conversation and returnsfalsefor payloads that are not from Ha Tang. It starts the engine when it is not running yet (cold start).setPushTokenis safe before the SDK is ready: registration is deferred and repeated when the visitor or the app version changes.- While the app is in the foreground and connected, or the visitor is looking at the conversation, the gateway does not send a push: the message arrives over the socket.
- On Android 13 (API 33) and later, ask for the
POST_NOTIFICATIONSpermission.
Ha Tang sends replies on the notification channel hatang_messages. Create a channel with this id in your app to give it a name and importance in the system settings; otherwise Android shows the notification on Firebase's fallback channel.
Deep links
The "we replied" email sent to the visitor contains a link of the form https://<your site>/c/<token>. If your app handles App Links for that domain (or a custom scheme with ?hatang_restore=<token>), restore the session from the token and open the chat:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val token = intent.data?.let { HatangChat.linkTokenFromUri(it) } ?: return // not a Hatang link
lifecycleScope.launch {
runCatching { HatangChat.restoreSession(token) }
HatangChat.open(this@MainActivity)
}
}
linkTokenFromUri accepts both /c/<token> and ?hatang_restore=<token> and returns null for other links. restoreSession stores the new session token, bootstraps again and emits HatangSessionChanged(reason = RESTORED). Restore tokens are single-use and expire, so do not store them.
Theming
By default the chat takes its brand color and light or dark mode from the site settings in the dashboard (HatangChatThemeMode.SITE), and adjusts colors to keep text readable. Override it in HatangConfig, or per screen through the composables' parameters:
HatangConfig(
siteId = "site_…",
appKey = "hak_…",
themeMode = HatangChatThemeMode.SYSTEM, // SITE | SYSTEM | LIGHT | DARK
theme = HatangChatTheme.light(accent = Color(0xFF0284C7), fontFamily = FontFamily(Font(R.font.be_vietnam_pro))),
darkTheme = HatangChatTheme.dark(accent = Color(0xFF38BDF8)),
)
themeMode | Meaning |
|---|---|
SITE | Follow the site's theme setting (light, dark, or automatic). Default. |
SYSTEM | Follow the device's dark theme setting. |
LIGHT, DARK | Always light or always dark. |
The UI labels every control for TalkBack, uses 48 dp touch targets, follows the system font size and reduces motion when the system asks for it.
Settings that follow the dashboard
Some parts of HatangChatScreen come straight from the site's widget settings (Support → Channels → your website). Changes there reach the app the next time the chat opens, with no code change:
- Header: the subtitle under the site name (in the current language), and whether it shows the online agents' photos or the business logo (or the site name's first letter when there is no logo).
- Greeting: on or off, and whether it shows on phones (an app always counts as a phone). When the sender is set to the online agent, that agent's name appears under the greeting while the team is online. The teaser bubble over the launcher exists only in the web widget.
- Contact buttons: Zalo, Messenger, phone and email appear as a scrollable row of pills under the header and open the link in the matching app. Override the "Call us" and "Email" labels with the
callUsandemailUsstrings.
With an older gateway that does not send these settings, the screen keeps its previous behavior: the greeting always shows, the header shows the agents, and there are no contact buttons. HatangConversationView has no top bar and follows only the greeting settings.
Languages
Vietnamese and English are built in. The SDK picks the language in this order: locale in HatangConfig, the visitor's stored choice, the site's default language, then the device language. Change it at runtime:
lifecycleScope.launch { HatangChat.setLocale(HatangLocale.EN) }
HatangChatStrings is a data class. Copy the built-in set and change what you need:
HatangConfig(
siteId = "site_…",
appKey = "hak_…",
strings = mapOf(
HatangLocale.VI to HatangChatStrings.VI.copy(placeholder = "Bạn cần hỗ trợ gì?", team = "Mộc Hương"),
HatangLocale.EN to HatangChatStrings.EN.copy(placeholder = "How can we help?"),
),
)
Strings you do not change keep their built-in text.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
HatangException(invalid_app_key 401) at startup | The appKey is wrong or belongs to another site. Check the Apps tab of the right website; keys start with hak_. |
HatangException(app_key_revoked 403) | The key was revoked in the dashboard. Ship an update with a new key. isAppKeyError lets you detect both key errors. |
state.connection is OUTDATED, event HatangProtocolOutdated | The gateway closed the socket with code 4004: this SDK version is too old for the protocol. The SDK stops reconnecting; update dev.hatang:chat and ship a release. |
rate_limit_exceeded | 5 messages per 10 seconds and 60 per minute per visitor; 30 bootstraps per minute per install. isTransient and isRateLimit tell you to retry later. |
| The emulator reaches a local API but the socket never connects | The gateway builds the WebSocket address from its own CHAT_URL (localhost), so 10.0.2.2 is not enough. Run adb reverse tcp:3002 tcp:3002 and keep baseUrl = "http://localhost:3002". On a real phone over Wi-Fi, set the gateway's CHAT_URL to your computer's LAN address and use that address as baseUrl. |
http:// calls fail in debug builds | Android blocks cleartext traffic by default. Allow it for your development host in a debug-only network security config; production always uses https://chat.hatang.dev. |
| No push notifications | setPushToken was never called, the app was in the foreground or showing the conversation, notifications are not allowed on Android 13+, or the website's Apps tab has no Firebase file. Use Send a test there to check the most recent device, and read the "Last error" field. |
| Notifications land in "Miscellaneous" | The app has not created the hatang_messages channel. See Push notifications. |
| Tapping a notification does not open the chat | The launcher activity does not pass its intent extras to handleNotification, or configure has not run yet when it does. |
Pass logger = logcatLogger() in HatangConfig to see socket frames and HTTP calls in Logcat.
Changelog
0.4.1 (2026-11-03): the Maven Central listing: a clearer description, this guide as project URL, Sota Labs as developer, and a LICENSE file. No code changes.
0.4.0 (2026-10-31): in-app purchases. HatangChat.purchaseToken(userId) gives the id to pass as setObfuscatedAccountId, so Google Play notifications reach the right customer; HatangChat.reportPurchase(...) after a purchase: Ha Tang checks the token with Google Play and shows the plan or purchase on the customer. Product cards carry basePlanId, offerId, billingPeriod, trialDays and the Google Play price set for the product.
0.3.0 (2026-10-14): the chat screen follows the site's new widget settings: a per-language subtitle under the site name, the business logo (or the site name's first letter) instead of the agents when the profile is "business", agent photos, the greeting's on/off and on-phones switches, the online agent's name under the greeting, and a row of Zalo, Messenger, call and email buttons under the header. Older gateways omit these settings and the screen looks as before. Adds SiteConfig.header, SiteConfig.greetingOptions and SiteConfig.contactButtons in chat-core, and the callUs and emailUs strings (required when you build HatangChatStrings with its constructor instead of copy).
0.2.0 (2026-10-13): a payment card switches to "Paid" as soon as the timeline holds an order_paid message or event for its order or payment, and paid or expired cards hide the transfer note; the VietQR code is drawn natively from the EMVCo payload (qr_payload, or the public checkout for older cards) because the gateway serves qr_image_url as SVG, with a spinner while loading and a retry button on failure (15 s timeout); product, order and payment cards no longer repeat their plain-text summary above the card, only the intro line. Adds PaidMarks, PaymentCard.statusPaid / isPaid(marks), PaymentCard.qrPayload, PaymentQR, ProductCard.intro and cardBubbleText in chat-core, and a dependency on com.google.zxing:core (encoder only) in chat.
0.1.0 (2026-10-03): first release: chat screen, inbox and launcher in Jetpack Compose (View-based apps open them with HatangChat.open), photo and file attachments, signed identity, FCM push helpers, Vietnamese and English, light and dark themes. chat-core passes the C01–C12 conformance suite.
Release notes are in the developer changelog. See also: Push notifications, Identity verification, Compatibility for protocol versions and the minimum gateway, and the realtime protocol.