Skip to content

Android

Markdown

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.

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.0")
}

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.

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

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:

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

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.

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

import com.conciergesdk.ui.ConciergeChat
ConciergeChat(modifier = Modifier.fillMaxSize(), onClose = { navController.popBackStack() })
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.

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.

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.

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.

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.

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.

Register the screens support answers may link to, once at launch. See Knowledge autopilot.

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:

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

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.

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

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

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

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

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.

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, OfferRedeemed, OfferUnhandled, IdentityRejected, AvailabilityChanged, ReviewPromptShown, Error.

Accent colors, title, welcome text and team name display come from your dashboard (cached for 5 minutes). Anything you set locally wins. See Appearance.

Concierge.appearance = ConciergeAppearance(
accentLight = Color(0xFF0A7AFF),
accentDark = Color(0xFF4DA3FF),
title = "Help",
colorScheme = ConciergeColorScheme.System, // or Light / Dark
)
// "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.
  • 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/ 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.