# Android SDK

# Android SDK

The Frak Android SDK brings referral tracking, rewards, and the sharing sheet to a native Android app. It is written in Kotlin, callable from Java, and ships as two artifacts so an app that only needs tracking never links a web view.

## Requirements

| Item | Value |
| --- | --- |
| Minimum SDK | 24 (Android 7.0) |
| Java / JVM target | 17 |
| Language | Kotlin 2.2 language level, Java call sites supported |
| License | Apache-2.0 |

## Install

Two artifacts, released together:

| Artifact | Contents |
| --- | --- |
| `id.frak.sdk:core` | Identity, config, rewards, interaction tracking, sharing links, app links. No UI, no web view. |
| `id.frak.sdk:ui` | The sharing sheet. Depends on `core`. |

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation("id.frak.sdk:core:1.0.0-beta.1")
    // Only if you show the sharing sheet
    implementation("id.frak.sdk:ui:1.0.0-beta.1")
}
```

Artifacts are published to Maven Central. Pin an exact version: if your build cannot resolve the coordinates above, ask the Frak team for the current release.

The SDK's own manifest declares the `INTERNET` permission and the `<queries>` entries needed to detect the Frak wallet app, so you do not add either by hand. It declares no activity and no intent filter: your app keeps ownership of its own deep links.

## Initialize

Call `Frak.initialize` once, in `Application.onCreate` or in your launcher Activity's `onCreate` after `super.onCreate`.

```kotlin
Frak.initialize(
    context = applicationContext,
    config = FrakConfig(merchantId = BuildConfig.FRAK_MERCHANT_ID) {
        metadata = FrakMetadata {
            name = "Your Store"
            currency = FrakCurrency.EUR
            homepageLink = "https://your-store.com"
        }
        deepLink = DeepLinkHandling.Automatic
        logLevel = FrakLogLevel.INFO
    },
)
```

```java
Frak.initialize(
        getApplicationContext(),
        new FrakConfig.Builder(BuildConfig.FRAK_MERCHANT_ID)
                .metadata(new FrakMetadata.Builder()
                        .name("Your Store")
                        .currency(FrakCurrency.EUR)
                        .homepageLink("https://your-store.com")
                        .build())
                .deepLink(DeepLinkHandling.Automatic)
                .logLevel(FrakLogLevel.INFO)
                .build());
```

The Kotlin trailing-lambda form is sugar over the same `Builder`, not a second implementation.

### Configuration options

| Option | Default | Meaning |
| --- | --- | --- |
| `merchantId` | `null` | Your merchant ID from the [business dashboard](https://business.frak.id/). When null, the merchant is resolved from `packageId` instead. |
| `packageId` | `null` | Falls back to `context.packageName`. |
| `metadata` | empty | Static merchant facts: `name`, `currency`, `lang`, `logoUrl`, `homepageLink`. |
| `deepLink` | `Automatic` | `Automatic`, `Manual`, or `Disabled`. See [Referral deep links](#referral-deep-links). |
| `trackingEnabled` | `true` | A hard floor. Setting it to `false` cannot be lifted at runtime. |
| `logLevel` | `NONE` | `NONE`, `ERROR`, `WARN`, `INFO`, `DEBUG`. |
| `logSink` | `null` | A `FrakLogSink` that replaces logcat. Must be thread-safe and must not throw. |

`homepageLink` is the last fallback of the share link chain: without it, a store-wide share with no product and no explicit link has nothing to point at.

**No default arguments, on purpose:** No public constructor in the SDK carries a Kotlin default argument. Adding a field to a defaulted constructor would break every already-shipped app binary with a `NoSuchMethodError`, so every merchant-facing input type uses a `Builder` instead. New options are always additive.

## The client

`Frak.client` throws if the SDK is not initialized; `Frak.clientOrNull` returns null instead. Every namespace member is a `suspend` function.

```kotlin
lifecycleScope.launch {
    val reward = Frak.client.rewards.best(
        RewardRequest { targetInteraction = "purchase" },
    )
}
```

The client exposes `environment`, `anonymousId()`, `resetAnonymousId()`, `setTrackingEnabled()`, `isTrackingEnabled()`, and five namespaces: `config`, `rewards`, `sharing`, `tracking`, `appLink`.

### Calling from Java

Every suspending member has a `CompletableFuture` twin named `*Async`. The work runs on the SDK's IO dispatcher and the future **completes on the main thread**, so a continuation can touch a `View` directly.

```java
Frak.getClient().getRewards()
        .bestAsync(new RewardRequest.Builder().targetInteraction("purchase").build())
        .thenAccept(reward -> banner.setText(reward == null ? "" : reward.getFormatted()));
```

**Never block on a twin from the main thread:** Completion needs a main-looper turn, and a blocked main thread never gives one. Calling `get()` or `join()` on a twin from the main thread is a deterministic ANR. Register a continuation with `thenAccept` or `whenComplete`, or block on a background thread.

## Tracking

```kotlin
// A confirmed order, on your order confirmation screen
when (val result = Frak.client.tracking.purchase(
    customerId = "cust_123",
    orderId = "order_456",
    token = "a-unique-order-token",
)) {
    is FrakResult.Success -> Log.i("frak", "order tracked")
    is FrakResult.Failure -> Log.w("frak", result.error.message)
}

// Any other interaction
Frak.client.tracking.track(Interaction.custom("added_to_cart"))
```

`track` and `purchase` succeed once the event is **durable**, not once it is delivered. Events are queued on disk oldest-first and retried, so an offline device still reports later.

`Interaction` is an opaque type built through static factories:

```kotlin
Interaction.custom("checkout")
Interaction.custom("checkout", mapOf("plan" to "pro"))
Interaction.sharing()
Interaction.arrival(referrerWallet, referrerClientId, referrerMerchantId, referralTimestamp)
```

You rarely build `arrival` yourself: `appLink.handleReferral` does it for you, and building a second one for the same link double-counts the arrival.

**Purchases still need a backend confirmation:** Calling `tracking.purchase` registers the order. The reward is only paid once your server confirms the sale with a signed webhook. See [Validate purchases from your backend](/guides/platforms/custom/backend/).

## Rewards

```kotlin
val campaigns = Frak.client.rewards.campaigns()

val best = Frak.client.rewards.best(
    RewardRequest {
        targetInteraction = "purchase"
        products = visibleProducts.map { product ->
            ProductDetails {
                productId = product.id
                name = product.title
            }
        }
    },
)
```

Call `best` **once per screen for the whole visible product set**, not once per row: the cache is keyed on the encoded product list. Both calls accept a `forceRefresh` overload that skips the cache and the backoff.

## Configuration resolution

```kotlin
val config = Frak.client.config.resolve()
config.displayName
config.displayLogoUrl
```

`resolve` is stale-while-revalidate with a five minute freshness window, so calling it on every screen is cheap. The resolved config is a read model: you read placements, component copy, and translations from it, and never construct one. See [Backend-driven configuration](/developers/concepts/backend-configuration/).

## Sharing

### Build a link yourself

```kotlin
val link = Frak.client.sharing.buildLink(
    SharingRequest {
        products = listOf(
            SharingProduct(title = product.title, link = product.link) {
                imageUrl = product.imageUrl
                utmContent = product.id
                details = ProductDetails {
                    productId = product.id
                    name = product.title
                    unitPrice = product.priceCents / 100.0
                }
            },
        )
        targetInteraction = "purchase"
        placement = "product-page"
    },
)
```

`buildLink` returns `null` only when there is nothing to link to (no `link`, no product link, and no `homepageLink`). It throws a `FrakError` when a link should have been buildable but was not, for example when tracking is disabled.

`AttributionParams` (`utmSource`, `utmMedium`, `utmCampaign`, `utmContent`, `utmTerm`, `via`, `ref`) can be set per call and merges field by field over your merchant-level defaults.

### The sharing sheet

The `id.frak.sdk:ui` artifact adds a ready-made sheet. Its whole public surface is `FrakSharing`, `SharingResult`, and `FrakSharingDefaults`.

```kotlin
private lateinit var sharing: FrakSharing

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // In onCreate, after super.onCreate. Not a property initializer: an Activity
    // has no ViewModelStore before that, and build() throws without one.
    sharing = FrakSharing.Builder(::onShareResult).build(this)
}

// When a share affordance becomes visible
sharing.warm()

// On the tap
sharing.present(SharingRequest { targetInteraction = "purchase" })
```

```kotlin
val sharing = remember { FrakSharing.Builder(::onShareResult) }.build()

Button(onClick = {
    sharing.present(SharingRequest { targetInteraction = "purchase" })
}) {
    Text("Share and earn")
}
```

The Compose `build()` warms the sheet on composition-enter, so there is no `warm()` call to place yourself.

`Builder.heightFraction(Float)` tunes the sheet height, between `0.3` and `1.0`, defaulting to `0.85`. `warm()` is cheap to call repeatedly, and `present` implies it.

The sheet reports exactly once per session, through your callback:

```kotlin
private fun onShareResult(result: SharingResult) {
    when (result) {
        is SharingResult.Shared -> Log.i("frak", "shared ${result.link}")
        is SharingResult.Copied -> Log.i("frak", "copied ${result.link}")
        SharingResult.InstallStarted -> Unit  // informational only
        SharingResult.WalletOpened -> Unit    // the wallet app was already installed and opened
        SharingResult.Dismissed -> Unit
        is SharingResult.Failed -> Log.w("frak", result.error.message)
    }
}
```

`InstallStarted` is informational: it does not mean anything was installed, and it is not a cue to call `openFrakApp` again.

## Referral deep links

`DeepLinkHandling.Automatic` (the default) registers an activity lifecycle observer that reads the inbound intent and calls `handleReferral` for you. Choose `Manual` to call it yourself, or `Disabled` to opt out entirely.

Your app still declares its own intent filters for the domain or scheme you send referral links to:

```xml title="AndroidManifest.xml"
<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="your-store.com" android:pathPrefix="/product" />
    </intent-filter>
</activity>
```

**launchMode matters:** Use `android:launchMode="singleTask"` and publish an `assetlinks.json` for your domain. A warm start delivers the intent through `onNewIntent` and `onResume`, never `onCreate`, and the SDK's observer reads both; each consumed intent is flagged so returning to the app does not re-track the same arrival.

The `appLink` namespace covers the rest:

```kotlin
Frak.client.appLink.handleReferral(url)     // decode, guard self-referral, track the arrival
Frak.client.appLink.isFrakAppInstalled()    // synchronous, no async twin
Frak.client.appLink.openFrakApp()           // opens the wallet, or the Play Store listing
Frak.client.appLink.installPageUrl(returnScheme, sessionId)
```

`handleReferral` returns whether a referral context was found. It is not a "stop routing" signal: your own navigation still runs.

Test an inbound link without a real referral:

```bash
adb shell am start -a android.intent.action.VIEW \
  -d "https://your-store.com/product?fCtx=test_token_123" your.app.id
```

## Consent and privacy

The SDK ships no consent UI. Wire your consent platform to:

```kotlin
Frak.client.setTrackingEnabled(false)  // stops tracking and purges anything queued
Frak.client.resetAnonymousId()         // rotates the local identity
```

For Play Data Safety, three things leave the device and only three:

| What | Play data type |
| --- | --- |
| Anonymous ID and the `customerId` you pass to `tracking.purchase` | Personal info, **User IDs** |
| Referral and sharing events | App activity, **App interactions** |
| `customerId`, `orderId`, and the checkout token | Financial info, **Purchase history** |

No advertising ID, no `ANDROID_ID`, no install referrer, no location, no contacts. The anonymous ID is a per-install keypair held in the Android Keystore, non-exportable, and gone on uninstall, which is why it is declared as a user ID rather than a device ID.

**Custom interaction data is yours to declare:** `Interaction.custom` carries a `Map<String, String>` that the SDK persists and transmits as given. Put an email or an internal user ID in it and the table above no longer describes your app.

Two caveats worth knowing before you build a compliance story on `setTrackingEnabled`: the decision is written asynchronously, so a withdrawal lost to a process kill reverts to enabled on the next launch, and the web SDK has no equivalent switch today.

## Shutting down

`Frak.shutdown()` cancels background work and unregisters the deep-link observer, after which `initialize` can run again. It is not a consent control: it records no decision.

## Next steps

[API reference](/developers/references/android/)
  [iOS SDK](/developers/integration/ios/)
  [Backend validation](/guides/platforms/custom/backend/)
  [Interactions](/developers/concepts/interactions/)
  [Mobile setup guide](/guides/platforms/custom/mobile/)