Android SDK

Kotlin library for Android 7.0+ (API 24). Currently at v0.1.0. Two artifacts: appmate is the core — every flow, with no UI-toolkit dependency — and appmate-compose adds native Jetpack Compose screens for the forms and the wishlist. Same API names as the iOS SDK wherever the platforms allow.

Install

The SDK is published through JitPack. Add the repository in settings.gradle.kts:

kotlin
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

Then the dependency in your app module's build.gradle.kts:

kotlin
dependencies {
    // Core: all flows, deep links, referrals, crash capture.
    implementation("com.github.fil-technology.appmate-android:appmate:0.1.0")

    // Optional: native Compose screens (feedback, report, contact,
    // crash, wishlist). Without it those open the hosted pages.
    implementation("com.github.fil-technology.appmate-android:appmate-compose:0.1.0")
}

The core brings in Kotlin coroutines and Google's Play Install Referrer library, nothing else. It declares the INTERNET permission for you.

Configure

Once at launch, in your Application:

kotlin
import cloud.appmate.RetentionFlow
import cloud.appmate.RetentionFlowConfig

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        RetentionFlow.configure(
            this,
            RetentionFlowConfig(
                appSlug = "my-android-app",
                baseUrl = "https://cancel.appmate.cloud",
                urlScheme = "myapp",
            ),
        )
    }
}

urlScheme is the deep-link scheme you set for the app in the dashboard (the same one your iOS app uses, if you have one).

Start the cancel flow

kotlin
RetentionFlow.startCancelFlow(
    userId = currentUser.id,
    attributes = mapOf("plan" to "monthly"),
) { link ->
    when (val action = link.action) {
        is RetentionFlowAction.OpenPremium -> navigateToPaywall(action.paywallId)
        is RetentionFlowAction.OpenOffer -> OfferRouter.present(action.id)
        is RetentionFlowAction.OpenSupport -> openSupport(action.topic, action.message)
        is RetentionFlowAction.OpenFeature -> openFeature(action.id)
        RetentionFlowAction.ManageSubscription ->
            RetentionFlow.presentManageSubscriptions()
        is RetentionFlowAction.ExternalUrl -> openInBrowser(action.url)
        RetentionFlowAction.ReturnToApp -> Unit
        else -> Unit // onboarding / wishlist / future actions
    }
}

The SDK opens the hosted flow in an in-app sheet, watches for the deep link the flow ends with, closes the sheet and calls you back on the main thread. Pass from = activity to present from a specific Activity; otherwise it uses the one on screen.

No manifest setup for this. The sheet is a WebView the SDK owns, so it intercepts {yourscheme}://retention-flow/action?...itself — you don't need an intent-filter for flows the SDK presents. (A Custom Tab can't report that final navigation back to the app, which is why the SDK doesn't use one.)

Soft-fail behaviour

If the AppMate server is unreachable, onAction is invoked with ManageSubscriptionso you can route the user straight to Google Play — they're never blocked from cancelling.

Manage subscriptions

Android has no in-app manage-subscriptions sheet, so this opens Google Play's subscriptions page. Pass the product id to land on that subscription directly.

kotlin
RetentionFlow.presentManageSubscriptions()
RetentionFlow.presentManageSubscriptions(productId = "pro_monthly")

Deep links from outside the app

Only needed for links that open your app from elsewhere — today that's the referral "already installed" fast path. Add an intent-filter for your scheme:

xml
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="retention-flow" />
    </intent-filter>
</activity>

AppMate URLs are namespaced, and the parser returns null for anything that isn't one, so chain it in front of your existing handler in onCreate and onNewIntent:

kotlin
private fun handle(intent: Intent?) {
    lifecycleScope.launch {
        // Referral invite link → redeems the code; null if it isn't one.
        RetentionFlow.redeemReferralFromUri(intent?.data, userId = user?.id)
            ?.refereeReward?.let { grantReward(it.amount, it.unit) }
    }
    if (RetentionFlow.handleDeepLink(intent) { link -> handleAppMate(link) }) return
    handleMyExistingDeepLinks(intent) // everything else
}

Onboarding funnel (web → app)

Recover the answers + email captured by a web onboarding funnel on first launch. See the onboarding guide for the full flow.

kotlin
// Deferred handoff — call on first launch:
lifecycleScope.launch {
    RetentionFlow.fetchOnboardingResult(userId = user?.id)?.let { result ->
        result.values(forStep = "goal").firstOrNull()?.let(::applyPreset)
        result.email?.let(::prefillSignup)
    }
}

// Or run the funnel in-app for an installed user:
RetentionFlow.startOnboardingFlow(userId = user.id) { result ->
    result?.let(::apply)
}

How the handoff works on Android

When an Android visitor finishes the funnel, the store button sends them to Google Play with the claim token on the Play Install Referrer. Play hands it to your app after install, so the SDK recovers the answers silently — no clipboard, no paste toast. For that to happen, set the app's Google Play URL in the dashboard (App settings → Advanced options).

The clipboard is the fallback for installs that didn't come through that link. Android only lets the focused app read the clipboard, and Android 12+ shows a "pasted from your clipboard" toast, so call this once your first screen is up — or pass readClipboard = false to rely on the install referrer alone.

Referral (share with a friend)

Install-attributed referrals — see the referral guide. Don't grant the referrer's reward on share; it's earned only when a friend installs.

kotlin
// Share:
RetentionFlow.referralShareLink(userId = user.id)?.let { url ->
    startActivity(Intent.createChooser(
        Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, url),
        null,
    ))
}

// New user, first launch (install referrer, then clipboard):
RetentionFlow.attributeReferral(userId = user.id)?.refereeReward?.let { reward ->
    // unit is "week" (free weeks) or your custom currency ("drop", …)
    grantReward(reward.amount, reward.unit)
}

// Referrer, every launch:
val earned = RetentionFlow.claimReferralRewards(userId = user.id)
if (earned.amount > 0) grantReward(earned.amount, earned.unit)

For an "Enter invite code" field, use redeemReferralResult(code) — it tells a mistyped code from a network blip so you can show the right message and retry only what is retryable:

kotlin
when (val r = RetentionFlow.redeemReferralResult(entered, userId = user.id)) {
    is ReferralRedeemResult.Attributed -> grant(r.attribution.refereeReward)
    ReferralRedeemResult.CodeNotFound -> show("That code isn't valid.")
    ReferralRedeemResult.SelfReferral -> show("You can't redeem your own code.")
    ReferralRedeemResult.InviteLimitReached -> show("This invite is full.")
    is ReferralRedeemResult.NetworkError,
    is ReferralRedeemResult.ServerError -> { show("Check your connection."); scheduleRetry() }
}

Feedback, report and contact forms

One call each. With appmate-compose installed they open the native Compose screen; without it, the hosted page in the in-app sheet. Either way the form is whatever you published in the dashboard — rating, categories, optional email, custom fields.

kotlin
RetentionFlow.presentFeedback(userId = user?.id) { /* submitted */ }
RetentionFlow.presentReport()
RetentionFlow.presentContact()

To place a form inside your own screen, use the composables from cloud.appmate.compose — they pick up your MaterialTheme:

kotlin
FeedbackScreen(userId = user?.id, onSubmitted = { navController.popBackStack() })
ReportScreen()
ContactScreen()

Building your own UI? Fetch the typed config and submit yourself: feedbackForm() / submitFeedback(…), reportForm() / submitReport(…), contactForm() / submitContact(…). They throw AppMateException; NotOpenmeans the flow isn't published.

Feature wishlist

kotlin
// The board, from anywhere (e.g. a Settings row):
RetentionFlow.presentWishlist(userId = user?.id)

// Or inside your own navigation (appmate-compose):
WishlistScreen(userId = user?.id)

// Or a custom UI on the typed data API:
val page = RetentionFlow.wishlistIdeas(sort = WishlistSort.Votes)
RetentionFlow.submitWishlistIdea(title = "Dark mode")
RetentionFlow.voteWishlistIdea(ideaId = page.items.first().id)

Pass your userId so votes and comments dedupe per user across devices; without it the SDK uses a per-install anonymous id. See the feature wishlist guide.

Crash reporting

Capture a crash, then ask the user about it — or report it silently — on the next launch. Uncaught exceptions are recorded with their stack trace; on Android 11+ native crashes and ANRs are picked up from what the OS recorded about the last exit. It chains to any handler already installed, so Crashlytics and friends keep working. Crash reporting →

kotlin
RetentionFlow.enableCrashDetection()

// Ask the user (the native form pre-fills the captured crash):
if (RetentionFlow.pendingCrash != null) RetentionFlow.presentCrashReport()

// …or send it without asking:
lifecycleScope.launch { RetentionFlow.reportPendingCrash() }

// Report something you caught yourself, with a log attached:
RetentionFlow.submitCrashReport(
    message = "Sync failed",
    diagnostics = CrashDiagnostics.from(error),
    attachments = listOfNotNull(CrashAttachment.file("app.log", logFile)),
)

Cross-app promotions

Promote your other apps at a named placement with one call. If the destination app is installed its deep link opens; otherwise the SDK shows your custom card (when configured) and opens the app's Google Play listing. Silent when nothing is eligible. Cross-app promotions →

kotlin
lifecycleScope.launch {
    RetentionFlow.promotions.present("cleaning_completed")
}

// In the destination app, on first launch:
RetentionFlow.promotions.pendingDestination()?.let { router.open(it.route) }
Each destination app needs an Android package name or Google Play URL in its dashboard settings, and the promotion must target Android (or no platform at all). Destinations without one are skipped for Android users.

Waitlist

kotlin
val result = RetentionFlow.waitlist.submit(
    email = email,
    area = "Haifa",
    approxLatitude = 32.7940,   // rounded to ~1.1 km on the device
    approxLongitude = 34.9896,
    source = "no-coverage-cta",
)
if (result.isSuccess) markJoinedLocally()

QR codes

kotlin
// A URL for your image loader…
val url = RetentionFlow.qrCodeUrl(RetentionFlow.QrFlow.Waitlist)

// …a Bitmap…
val bitmap = RetentionFlow.qrCode(RetentionFlow.QrFlow.Referral, referralCode = code)

// …or the composable (appmate-compose):
RetentionFlowQr(flow = RetentionFlow.QrFlow.Referral, referralCode = code)

Styling is configured per flow in the dashboard — see QR codes.

Shake for feedback

Let testers shake the device on any screen to open a bottom sheet of your flows. Configure it once at launch; the SDK listens to the accelerometer only while your app is in the foreground. Open the same menu from a button with RetentionFlow.presentMenu().

kotlin
RetentionFlow.enableShakeMenu(
    title = "Help us improve",
    message = "Shake any time to reach us.",
    userId = user?.id,                         // forwarded to whichever flow opens
    items = listOf(
        RetentionFlowMenuItem.suggestFeature(),
        RetentionFlowMenuItem.reportBug(),
        RetentionFlowMenuItem.contact(),
        // .feedback(), .reportCrash(), .cancelSubscription(),
        // RetentionFlowMenuItem.custom("Email us") { openMail() },
    ),
)

Rows show an emoji by default; pass iconRes to use one of your drawables, and flowSlug to target a non-primary flow. The web flows live on your apex host — the SDK derives it from baseUrl (dropping a leading cancel.); set webBaseUrl in the config if you use custom domains.

Action reference

  • ReturnToApp — open your home screen.
  • OpenPremium(paywallId) — show your paywall; paywallId may be set to target a variant.
  • OpenOffer(id) — your app maps the id to a Play Billing offer, RevenueCat offering, or custom paywall.
  • OpenSupport(topic, message) — pre-fill your support screen.
  • OpenFeature(id) — deep-link to a feature or tutorial.
  • ManageSubscription — call presentManageSubscriptions().
  • ExternalUrl(url)— open in the user's browser.
  • OnboardingComplete(claimToken) — a web-to-app funnel finished; startOnboardingFlow handles it for you.
  • WishlistSubmitted(ideaId) — a hosted wishlist page the SDK opened submitted a new idea.
  • None — future / unknown — handle defensively.

Differences from the iOS SDK

  • Hosted flows open in an SDK-owned WebView sheet, not the system browser, so the return deep link needs no manifest entry.
  • Deferred onboarding + referral attribution uses the Play Install Referrer first and the clipboard second (iOS is clipboard-only).
  • presentManageSubscriptions() hands off to Google Play; there is no in-app sheet to present.
  • Promotions open the Google Play listing — Android has no equivalent of Apple's in-app install overlay.
  • Data calls are suspend functions; the form and wishlist calls throw AppMateException, everything else returns a result type or null.

Full source, tests and a sample app: github.com/fil-technology/appmate-android.