# Android SDK `dev.hatang:chat`

`dev.hatang:chat` adds a native support chat to your Android app. It talks the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) 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.

> **Info: Version**
>
> `dev.hatang:chat` **0.4.1** · protocol v1 · Android 8.0 (API 26)+ · Kotlin 1.9+ · Jetpack Compose (BOM 2024.12+) · [Maven Central](https://central.sonatype.com/artifact/dev.hatang/chat) · [Changelog](https://hatang.dev/docs/changelog) · [API reference (Dokka)](https://hatang.dev/docs/reference/android/index.html)

> **Note**
>
> 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 {#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.

1. In the dashboard, open **Support → Channels → Add channel → Mobile app**.
2. 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.
3. 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`.

```kotlin title="app/build.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 {#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.

```kotlin title="App.kt"
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](#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](#theming). |
| `strings` | `null` | Wording overrides per locale; see [Languages](#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](#commerce). |

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 {#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](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md).

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

```js title="Node.js"
const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex");
```

```php title="PHP"
$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

```kotlin
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-and-embed}

### Open the chat screen

From anywhere with a `Context`, including View-based apps:

```kotlin
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`.

```kotlin
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` |

```kotlin
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 {#commerce}

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:

```kotlin
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](https://hatang.dev/docs/guides/support/in-app-purchases.md).

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

```kotlin
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 {#events-and-data}

`HatangChat.events` is a `Flow<HatangEvent>` that follows re-configuration. Collect it in a lifecycle-aware scope:

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

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

```kotlin
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 {#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](https://hatang.dev/docs/guides/sdks/push-notifications.md). 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`:

```kotlin title="AppMessagingService.kt"
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`:

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

```kotlin title="MainActivity.kt"
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 for `hatang: "1"` (also inside a nested `data` JSON 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 returns `false` for payloads that are not from Ha Tang. It starts the engine when it is not running yet (cold start).
- `setPushToken` is 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_NOTIFICATIONS` permission.

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

```kotlin
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 {#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:

```kotlin
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 {#dashboard-settings}

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 `callUs` and `emailUs` strings.

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

```kotlin
lifecycleScope.launch { HatangChat.setLocale(HatangLocale.EN) }
```

`HatangChatStrings` is a data class. Copy the built-in set and change what you need:

```kotlin
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 {#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](#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 {#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](https://hatang.dev/docs/changelog). See also: [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md), [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md), [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md) for protocol versions and the minimum gateway, and the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md).

Source: https://hatang.dev/docs/guides/sdks/android
