# Android

> Native Jetpack Compose support chat for Android, minSdk 24.

Source: https://conciergesdk.com/docs/android/

Kotlin, Jetpack Compose, Material 3. minSdk 24.

The SDK depends only on Kotlin, Compose, coroutines, OkHttp, kotlinx.serialization, Play Integrity and Play In-App Review (plus the AndroidX Activity and Lifecycle glue Compose needs). No Firebase, no image library, no DI framework.

## Install

```kotlin
// settings.gradle.kts
dependencyResolutionManagement {
    repositories { google(); mavenCentral() }
}

// app/build.gradle.kts
android {
    compileSdk = 37 // required: the SDK and its Compose dependencies compile against Android SDK 37
    defaultConfig {
        minSdk = 24
    }
}

dependencies {
    implementation("com.conciergesdk:concierge:0.1.1")
}
```

The artifact is a prebuilt, R8 optimized AAR on Maven Central. Its ProGuard rules travel inside it, so a minified release build needs no extra keep rules. A lower `compileSdk` fails the build at `checkDebugAarMetadata`; `targetSdk` can stay where it is.

## Configure

Call once in `Application.onCreate`. This makes no network calls. The device registers lazily, the first time chat opens or a push token arrives.

```kotlin
import android.app.Application
import com.conciergesdk.Concierge

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        Concierge.configure(this, appKey = "app_...")
        // Self-hosted or local API:
        // Concierge.configure(this, appKey = "app_...", baseUrl = "http://10.0.2.2:4000")
    }
}
```

If the app had no `Application` subclass yet, register it in `AndroidManifest.xml`:

```xml
<application
    android:name=".MyApp"
    ... >
```

The emulator reaches a local API on your computer at `10.0.2.2`. Android blocks plain HTTP by default, so allow it for that host in debug builds only:

```xml
<!-- app/src/debug/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>

<!-- app/src/debug/res/xml/network_security_config.xml -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
    </domain-config>
</network-security-config>
```

Without it the chat stays offline and the SDK reports `CLEARTEXT communication to 10.0.2.2 not permitted`.

## Open the chat

```kotlin
// val context = LocalContext.current
Button(onClick = { Concierge.open(context) }) { Text("Support") }
```

`Concierge.open(context)` works from anywhere you have a `Context` (an Activity, a click listener, a notification tap). Or embed the chat anywhere in Compose (a tab, a bottom sheet, a navigation destination):

```kotlin
import com.conciergesdk.ui.ConciergeChat

ConciergeChat(modifier = Modifier.fillMaxSize(), onClose = { navController.popBackStack() })
```

## Unread badge

```kotlin
val unread by Concierge.unreadCount.collectAsStateWithLifecycle()
BadgedBox(badge = { if (unread > 0) Badge { Text("$unread") } }) { /* Support button */ }
```

Once this device has used chat, the count updates live while the app is in the foreground (a team reply moves the badge with the chat closed), refreshes when the app returns to the foreground or a Concierge push arrives, and drops to zero while the chat is visible.

## Identify your user

History follows the user across devices once identified. `token` is an optional HS256 JWT you sign on your server with the app's identity secret (required when "require verified identity" is on). See [Identity verification](https://conciergesdk.com/docs/identity.md).

```kotlin
Concierge.identify(userId = user.id, name = user.name, email = user.email, token = jwtFromYourServer)
Concierge.setMetadata(mapOf("plan" to "pro", "accountId" to user.accountId))
Concierge.setContext(mapOf("plan" to "pro", "screen" to "Paywall", "appState" to mapOf("launches" to 3)))
Concierge.breadcrumb("tapped_restore")

// On sign out: forgets the user and local history, the device continues anonymously.
Concierge.logout()
```

`setMetadata` sets string fields next to the conversation. `setContext` is structured context for your team and the AI agent: it merges into what you set before, a `null` value removes a key, and `clearContext()` removes them all. Breadcrumbs keep the last 50. Metadata, context and breadcrumbs are stored on the device and sent in a batch shortly after they change once the device has registered (the first time chat opens), so setting them before that is fine.

## Language

The chat ships in 34 languages and follows the app locale (including `AppCompatDelegate.setApplicationLocales` and the Android 13+ per-app language). Override it with a BCP 47 tag, or `null` to follow the app again. An open chat updates in place.

```kotlin
Concierge.locale = "ar"   // Arabic, laid out right to left
Concierge.locale = "de"
Concierge.locale = null
```

Arabic, Hebrew and Persian lay the chat out right to left even when the app doesn't declare `android:supportsRtl`. See [Localization](https://conciergesdk.com/docs/localization.md).

## Review prompt

```kotlin
lifecycleScope.launch {
    val result = Concierge.maybeRequestReview(activity, ReviewMoment.Milestone)
}
```

Moments: `Milestone`, `PurchaseCompleted`, `ContentCreated` and `Custom(name)`. The SDK calls the Play In-App Review API only when its local rules pass (not in the first 3 days, 30 days between attempts, once per app version, 3 per year, never over the chat) and the server agrees (no recent complaint or negative sentiment). It never asks a question first. `result.shown` means the Play API was called; `result.reason` says why not otherwise. After the chat closes the SDK also asks on its own when the server saw praise; turn that off with `Concierge.reviewPromptAfterSupport = false`.

## Screen links

Register the screens support answers may link to, once at launch. See [Knowledge autopilot](https://conciergesdk.com/docs/knowledge.md).

```kotlin
Concierge.registerScreen("settings.backup", "Backup settings", "myapp://settings/backup")
Concierge.unregisterScreen("settings.backup")   // its buttons stop showing
```

Tapping the button closes the chat and opens the deep link with an `ACTION_VIEW` intent limited to your own app's package. To route in app instead (Navigation Compose), set a handler:

```kotlin
Concierge.onOpenScreen { id, deepLink ->
    navController.navigate(Uri.parse(deepLink))
}
```

The deep link never leaves the device: Concierge learns only the id and title, and only while the [knowledge autopilot](https://conciergesdk.com/docs/knowledge.md) is on. When an AI answer is about a registered screen, it can end with an **open screen** button; the SDK shows it only if that id is registered on this device. Ids are 1 to 64 letters, digits, `.`, `_` or `-`, starting with a letter; titles are 1 to 120 characters; up to 100 screens. Registering an id again replaces it, and invalid input is logged and ignored.

## Push (FCM)

Concierge doesn't depend on Firebase. Forward the token and data payloads from your own `FirebaseMessagingService` (registered in the manifest with the `com.google.firebase.MESSAGING_EVENT` intent filter). Full guide: [Push notifications](https://conciergesdk.com/docs/push.md).

```kotlin
override fun onNewToken(token: String) = Concierge.registerPushToken(token)

override fun onMessageReceived(message: RemoteMessage) {
    if (Concierge.handleNotification(message.data)) {
        // Ours: show a notification that calls Concierge.open(context) when tapped.
        return
    }
    // Yours.
}
```

## Cards

Review cards open their URL (use a `market://` or Play Store link). Paywall cards call you back with the paywall id. Offer cards are redeemed on the server and handed to `onOfferCard`; the SDK has no billing dependency, so store offers need it (see [Offers](https://conciergesdk.com/docs/offers.md)):

```kotlin
Concierge.onPaywallCard { paywallId -> showPaywall(paywallId) }

Concierge.onOfferCard { redemption ->
    billing.launch(redemption.mechanism, obfuscatedAccountId = redemption.accountToken)
}
```

### Link purchases to customers

The refund desk, receipts and renewal heads ups only know a purchase is this customer's when the store says so. Pass the customer's account token as the obfuscated account id on your own purchases (it is derived from the verified user id, see [Offers](https://conciergesdk.com/docs/offers.md#link-purchases-to-customers)):

```kotlin
private val UUID_RE = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")

fun conciergeAccountToken(userId: String): String =
    if (UUID_RE.matches(userId)) userId.lowercase()
    else UUID.nameUUIDFromBytes(userId.toByteArray(Charsets.UTF_8)).toString()

val params = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(productDetailsParams)
    .setObfuscatedAccountId(conciergeAccountToken(user.id))
    .build()
```

With RevenueCat, `Purchases.sharedInstance.logIn(user.id)` with the same id you sign into the identity token does the job instead. Offer card purchases keep using `redemption.accountToken`.

## Wire your paywall, offers and reviews

Your app already has a paywall, products and maybe offers and a review prompt. Concierge cards should open those, not a new screen. The [setup prompt](https://conciergesdk.com/docs/agents.md) does this for you: it finds what the app has (RevenueCat, including RevenueCat Paywalls, Superwall, Adapty, Qonversion, Play Billing or a custom paywall), wires the handlers below and prefills your account. By hand:

- **Paywall cards** call `onPaywallCard(paywallId)`. Map the id to your existing paywall, placement or offering, and open the main paywall for any id you don't recognize: the dashboard sends `default` unless you type another id.
- **Offer cards** call `onOfferCard(redemption)` after the server redeems them. Run your existing purchase flow for `redemption.mechanism` and pass `redemption.accountToken` to the store so the sale is attributed to the card.
- **Review cards** open the store review page by themselves, but only when the app's store listing is set: the App Store id and Google Play package under **Settings, General** in the dashboard (or `update_app_settings` on the [MCP server](https://conciergesdk.com/docs/mcp.md)).
- **Review prompts:** replace your own native review request with `maybeRequestReview` at the same moment, so the two don't compete for the system's yearly limit.
- **After a purchase** in your own flow, set the plan in the context and ask for a review at that moment.
- **Purchase identity:** see [Link purchases to customers](#link-purchases-to-customers).

```kotlin
Concierge.onPaywallCard { paywallId -> Paywall.show(currentActivity, paywallId) }   // unknown ids open the main paywall

Concierge.onOfferCard { r ->
    when (val m = r.mechanism) {
        is ConciergeOfferMechanism.AndroidOffer -> Billing.buy(currentActivity, m.productId, m.basePlanId, m.offerId, obfuscatedAccountId = r.accountToken)
        is ConciergeOfferMechanism.Product -> Billing.buy(currentActivity, m.productId, obfuscatedAccountId = r.accountToken)
        is ConciergeOfferMechanism.OfferCode -> currentActivity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(m.redeemUrl)))
        is ConciergeOfferMechanism.Paywall -> Paywall.show(currentActivity, m.paywallId)
        is ConciergeOfferMechanism.Comp -> Unit   // granted on the server
        else -> Paywall.show(currentActivity, "default")
    }
}

// After a purchase in your own flow
Concierge.setContext(mapOf("plan" to productId))
lifecycleScope.launch { Concierge.maybeRequestReview(activity, ReviewMoment.PurchaseCompleted) }
```

The handlers run on the main thread; keep a reference to the current Activity for the billing flow. The Android SDK has no billing dependency, so store offers need `onOfferCard`. With Play Billing, take the offer token from `productDetails.subscriptionOfferDetails` where `basePlanId` and `offerId` match, and call `setObfuscatedAccountId(r.accountToken)`. With RevenueCat, buy the `SubscriptionOption` whose id is `"$basePlanId:$offerId"` after `Purchases.sharedInstance.setAttributes(mapOf("concierge_card" to r.accountToken))`. Replace the `ReviewManager` flow with `Concierge.maybeRequestReview`.

The SDK reports which handlers are set (SDK 0.1.1 or later), so the dashboard's Connect page, the `verify_install` MCP tool and `npx @conciergesdk/cli doctor` show whether paywall and offer cards are wired and whether review cards have a link. A card with no handler logs a warning and emits ``PaywallUnhandled` or `OfferUnhandled``.

## Events

```kotlin
Concierge.addEventListener { event ->
    when (event) {
        is ConciergeEvent.MessageSent -> analytics.track("support_message_sent")
        is ConciergeEvent.OfferRedeemed -> analytics.track("support_offer", event.redemption.offerId)
        else -> Unit
    }
}
```

Events: `ChatOpened`, `ChatClosed`, `MessageSent`, `MessageReceived` (with `fromAi`), `UnreadCountChanged`, `CardShown`, `CardTapped`, `ReviewCardTapped`, `PaywallCardTapped`, `PaywallUnhandled`, `OfferRedeemed`, `OfferUnhandled`, `IdentityRejected`, `AvailabilityChanged`, `ReviewPromptShown`, `Error`.

## Appearance

Accent colors, title, welcome text and team name display come from your dashboard (cached for 5 minutes). Anything you set locally wins. See [Appearance](https://conciergesdk.com/docs/appearance.md).

```kotlin
Concierge.appearance = ConciergeAppearance(
    accentLight = Color(0xFF0A7AFF),
    accentDark = Color(0xFF4DA3FF),
    title = "Help",
    colorScheme = ConciergeColorScheme.System, // or Light / Dark
)
```

## Reset and suspend

```kotlin
// "Erase all data": wipe Concierge on this device.
Concierge.reset()

// A decoy or guest session: Concierge goes invisible and silent.
Concierge.suspend()
Concierge.resume()
Concierge.isSuspended
```

- `reset()` wipes Concierge on this device back to a fresh install without a network request: the device token in secure storage, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It also forgets the screens from `registerScreen` and the `onOpenScreen` handler, so register them again after a reset. It closes the chat. Tools, the other handlers, event listeners and `Concierge.appearance` stay, and the next open registers a new device.
- While suspended the SDK sends no request and keeps no socket open, the chat closes and won't open, the support button and badge are hidden, the unread count reads 0 and no events fire. Consent prompts and tool calls wait: a pending call is picked up after `resume()` or expires on the server.
- `resume()` brings the entry points back, refreshes the unread count and sends anything queued. Suspension lasts until the app process ends, so call `suspend()` again on the next launch if needed.

## How it behaves

- **Instant open:** the last 200 messages are cached on disk and drawn before any network call.
- **Realtime only while visible:** the WebSocket opens when the chat reaches STARTED and closes on STOPPED. Reconnects use jittered exponential backoff and catch up from the last message seen.
- **Offline outbox:** messages are persisted before sending and delivered in order. Each has a client generated `clientId`, so retries after a lost response never duplicate. Server rejections (4xx) show "Not sent" with Retry and Delete.
- **Photos:** picked with the Android Photo Picker (no storage permission), resized to 1600px, JPEG compressed, uploaded once, then sent.
- **Paging:** scrolling to the top loads 50 older messages at a time.
- **Read receipts and typing:** the latest customer message shows Delivered or Read; team and AI typing shows in the list.
- **AI messages** always carry an "AI" label, and TalkBack reads them as "AI assistant". The app's `showAsAi` setting only picks the name ("AI assistant" or your app's name with the AI badge). When the AI agent is on, a line under the welcome says replies may come from an AI assistant.
- **Device token** is encrypted with an AES-GCM key held in the Android Keystore.
- **Accessibility:** each message is one TalkBack element, status changes are announced, text follows the system font scale, dark mode follows the system.

## Sample app

`sample/` points at `http://10.0.2.2:4000` (the local API from the emulator). Cleartext HTTP is allowed for that host in debug builds only. It has a Support button with an unread badge, an identify toggle and a breadcrumb button.
