diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..31436c3fa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# Iterable Android SDK + +This is the source repository for the Iterable Android SDK. + +## SDK Version + +To find the latest released version, read `CHANGELOG.md` — the first `## [x.y.z]` entry after `## [Unreleased]` is the latest stable release. Always use this version — never hardcode or assume a version number. + +Maven coordinates (use the version from CHANGELOG.md): +- `com.iterable:iterableapi:` — core SDK +- `com.iterable:iterableapi-ui:` — UI components (inbox, in-app) + +## For Agents Integrating the SDK + +If you are integrating the Iterable Android SDK into an app, **read `docs/ai/integration-guide.md` before writing any code**. It contains step-by-step instructions, critical gotchas, and traps that will save hours of debugging. + +## Project Structure + +- `iterableapi/` — Core SDK module +- `iterableapi-ui/` — UI module (in-app, inbox, embedded) +- `sample-apps/` — Sample integrations +- `docs/ai/` — AI agent guides diff --git a/docs/ai/integration-guide.md b/docs/ai/integration-guide.md new file mode 100644 index 000000000..5e7399ae3 --- /dev/null +++ b/docs/ai/integration-guide.md @@ -0,0 +1,452 @@ +# Golden Path: Iterable Android SDK Integration + +A step-by-step guide for an AI agent to integrate the Iterable SDK into any Android app. Contains critical gotchas and traps discovered through real integration failures. + +> **Agent note:** Throughout this guide, placeholders like `YourApplication`, `your.package.name`, and `yourscheme` must be replaced with values from the user's actual project. When in doubt, ask the user. + +--- + +## Prerequisites (ask the user FIRST) + +Before writing any code, collect these from the user: + +1. **Iterable API key** — must be a Mobile (client-side) key +2. **Is JWT required?** — if yes, get the JWT secret immediately +3. **Firebase `google-services.json`** — from Firebase Console +4. **Push integration name** — what it's called in Iterable dashboard (often the app package name) +5. **Embedded message placement IDs** — auto-generated by Iterable, cannot be assumed +6. **The app's custom deep link scheme** (if any) — e.g. `myapp://` +7. **The app's Application class name** — or whether one needs to be created + +**CRITICAL**: If the user provides an API key and JWT secret together, do NOT ignore the JWT secret. A JWT-required key will cause ALL SDK calls to silently fail with zero errors if no auth handler is implemented. + +--- + +## Step 1: Dependencies + +> **Agent note — version resolution:** Do NOT use the hardcoded versions below. Before adding any dependency, look up the latest stable version for each: +> - **Iterable SDK**: Check `CHANGELOG.md` in this repo — first entry after `[Unreleased]` is the latest. Use that version for both `iterableapi` and `iterableapi-ui`. +> - **Firebase BOM**: Look up the latest version from [Firebase Android releases](https://firebase.google.com/support/release-notes/android) or Maven Central. +> - **Google Services plugin**: Look up the latest version from Maven Central (`com.google.gms:google-services`). +> +> The versions shown below are examples only. + +**`build.gradle.kts` (project-level)**: +```kotlin +plugins { + id("com.google.gms.google-services") version "" apply false +} +``` + +**`app/build.gradle.kts`**: +```kotlin +plugins { + id("com.google.gms.google-services") +} + +// Load secrets from local.properties (NEVER hardcode keys) +import java.util.Properties +import java.io.FileInputStream +val localProperties = Properties() +val localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localProperties.load(FileInputStream(localPropertiesFile)) +} + +android { + defaultConfig { + buildConfigField("String", "ITERABLE_API_KEY", "\"${localProperties.getProperty("ITERABLE_API_KEY", "")}\"") + buildConfigField("String", "ITERABLE_JWT_SECRET", "\"${localProperties.getProperty("ITERABLE_JWT_SECRET", "")}\"") + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation("com.iterable:iterableapi:") + implementation("com.iterable:iterableapi-ui:") + implementation(platform("com.google.firebase:firebase-bom:")) + implementation("com.google.firebase:firebase-messaging") +} +``` + +**`local.properties`** (gitignored): +``` +ITERABLE_API_KEY=your_key_here +ITERABLE_JWT_SECRET=your_secret_here +``` + +Place `google-services.json` in `app/`. + +--- + +## Step 2: SDK Initialization (Application class) + +> **Agent note:** Replace `YourApplication` with the app's actual Application class. Replace `"your.package.name"` with the push integration name from the user. Replace `"yourscheme"` with the app's custom deep link scheme. If the user doesn't need certain handlers (e.g. no embedded messaging, no deep links), omit those config lines. + +```kotlin +class YourApplication : Application() { + override fun onCreate() { + super.onCreate() + + val config = IterableConfig.Builder() + .setAutoPushRegistration(true) + .setPushIntegrationName("your.package.name") // MUST match Iterable dashboard exactly + .setInAppHandler(AppInAppHandler()) + .setCustomActionHandler(AppCustomActionHandler()) + .setUrlHandler(AppUrlHandler(this)) + .setAllowedProtocols(arrayOf("yourscheme")) // REQUIRED for custom deep link schemes + .setAuthHandler(AppAuthHandler { getFreshEmail() }) // REQUIRED if JWT key — see note below + .setEnableEmbeddedMessaging(true) // Only if using embedded messages + .build() + + IterableApi.initializeInBackground(this, BuildConfig.ITERABLE_API_KEY, config) { + Log.d("Iterable", "SDK initialized") + // WARNING: Do NOT call setEmail() here. + // It poisons the SDK's auth retry state before the auth manager is ready. + // Let the login/email screen handle setEmail for both fresh login and app restart. + } + } +} +``` + +### Gotcha: `initializeInBackground` takes 4 args +`initializeInBackground(context, apiKey, config, callback)` — the callback is the 4th arg. + +### Gotcha: Do NOT call setEmail in the init callback +The SDK's `IterableAuthManager` has internal retry limits. Calling `setEmail` too early consumes the retry budget, causing `AUTH_TOKEN_MISSING` for all subsequent calls in the same process. + +### Gotcha: Auth handler must read the email FRESH every time +The SDK calls `onAuthTokenRequested()` at unpredictable times (token refresh, retries, etc.). The lambda or function that provides the email to the auth handler must **read from the source of truth every time** (e.g. DataStore, SharedPreferences, database). Do NOT cache the email in a variable at startup and pass that — the cached value will be empty or stale if the user logs in after app launch, causing `AUTH_TOKEN_NULL`. + +--- + +## Step 3: User Identification (Login/Email Screen) + +> **Agent note:** Adapt this to the app's login flow. Replace session checks with whatever mechanism the app uses to track logged-in state and store the user's email. + +```kotlin +// On app restart (user already logged in): +if (isUserLoggedIn()) { + IterableApi.onSDKInitialized { + IterableApi.getInstance().setEmail(getSavedEmail()) + } + navigateToMainScreen() + return +} + +// On fresh login — use setEmail's success callback to chain updateUser: +IterableApi.onSDKInitialized { + IterableApi.getInstance().setEmail( + email, + { // onSuccess — JWT auth is complete, safe to call updateUser now + val dataFields = JSONObject() + dataFields.put("name", name) + IterableApi.getInstance().updateUser(dataFields) + }, + { reason, _ -> // onFailure + Log.e("Iterable", "setEmail failed: $reason") + } + ) +} +``` + +> **Agent note:** `setEmail` has multiple overloads. Use the one with `SuccessHandler` and `FailureHandler` callbacks: `setEmail(email, onSuccess, onFailure)`. This ensures `updateUser` only fires after JWT authentication completes successfully — never use `Handler.postDelayed` or arbitrary delays to sequence SDK calls. + +### Gotcha: Don't call registerForPush() explicitly +`setAutoPushRegistration(true)` handles it automatically when `setEmail` is called. + +### Gotcha: Always wrap SDK calls in `onSDKInitialized` +The SDK init is async. Calling `setEmail` before init completes silently fails. + +### Gotcha: Never use `Handler.postDelayed` to sequence SDK calls +The SDK provides proper async callbacks. Always use `setEmail(email, onSuccess, onFailure)` and chain dependent calls in `onSuccess`. Arbitrary delays are fragile and will break under varying network conditions. + +--- + +## Step 4: Logout + +```kotlin +IterableApi.getInstance().setEmail(null) +// Then clear the app's own session/auth state +``` + +--- + +## Step 5: Event Tracking + +> **Agent note:** Adapt event names and data fields to the app's domain. These are examples. + +```kotlin +// Custom event +val dataFields = JSONObject().apply { + put("itemName", item.name) + put("itemPrice", item.price) +} +IterableApi.getInstance().track("viewedItem", dataFields) + +// Purchase +val items = cartItems.map { CommerceItem(it.id.toString(), it.name, it.price, 1) } +IterableApi.getInstance().trackPurchase(totalAmount, items) +``` + +--- + +## Step 6: JWT Authentication + +> **Agent note — IMPORTANT decision point:** +> - If the app **already has a JWT token generator** (e.g. a server endpoint or local utility that produces Iterable-compatible JWTs), use it. Wire it into `AppAuthHandler.onAuthTokenRequested()`. +> - If the app **does NOT have JWT generation** and the user has indicated JWT is required, **STOP and inform the user**: "The Iterable SDK requires a JWT auth handler, but this app has no JWT token generator. A JWT generator must be implemented before proceeding with the Iterable JWT integration. Production tokens should be generated server-side." Do NOT create a mock JWT generator — it is insecure and not suitable for any environment. +> - If JWT is **not required** (user confirmed), skip this step entirely and omit `.setAuthHandler()` from the config in Step 2. + +### AppAuthHandler + +> **Agent note:** The auth handler needs access to the current user's email and the app's existing JWT generator. Adapt the constructor to accept whatever dependencies are needed to retrieve a token. The example below shows a constructor that accepts a token-generating lambda — adapt it to the app's architecture. + +```kotlin +class AppAuthHandler( + private val generateToken: () -> String? +) : IterableAuthHandler { + override fun onAuthTokenRequested(): String? = generateToken() + override fun onTokenRegistrationSuccessful(authToken: String) { + // WARNING: This is a LOCAL callback, NOT a server confirmation. + // The server may still reject the token. + } + override fun onAuthFailure(authFailure: AuthFailure) { + Log.e("IterableAuth", "Auth failure: ${authFailure.failureReason}") + } +} +``` + +### JWT Gotchas +- **Secret encoding** (if generating tokens locally): Use the secret as a raw UTF-8 string. Do NOT hex-decode or base64-decode. +- **`onTokenRegistrationSuccessful` is NOT a server confirmation** — it only means the SDK stored the token locally. Check `onAuthFailure` for server rejections. +- **After changing auth code, always logout/login** — the SDK caches auth state. +- **Don't fire multiple SDK calls simultaneously** — `setEmail`, `updateUser`, and `registerForPush` all competing for auth causes `AUTH_TOKEN_MISSING` bursts. Use `setEmail(email, onSuccess, onFailure)` and chain `updateUser` inside `onSuccess`. + +--- + +## Step 7: Push Notifications + +### FirebaseMessagingService + +> **Agent note:** Replace `AppFirebaseMessagingService` with a name that fits the project's naming conventions. + +```kotlin +class AppFirebaseMessagingService : IterableFirebaseMessagingService() { + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + } + override fun onNewToken(token: String) { + super.onNewToken(token) + } +} +``` + +### AndroidManifest.xml +```xml + + + + + + + +``` + +### Runtime permission (Android 13+) + +> **Agent note:** This is REQUIRED for push notifications to work on Android 13+ (API 33+). Without it, the system silently blocks all notifications. Add this to the app's main activity (or wherever the app first loads). Adapt it to the app's existing permission-handling pattern if one exists. + +```kotlin +// In your main activity: + +private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() +) { granted -> + Log.d("Iterable", "Notification permission granted: $granted") +} + +// Call this in onCreate(): +private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } +} +``` + +Required imports: +```kotlin +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +``` + +### Dashboard setup (user must do this) +- Upload Firebase service account JSON (NOT the deprecated Server Key) +- The push integration name must EXACTLY match `setPushIntegrationName()` value +- Create a Message Type for the Push channel before creating templates + +--- + +## Step 8: In-App Messaging + +```kotlin +import com.iterable.iterableapi.IterableInAppHandler +import com.iterable.iterableapi.IterableInAppMessage +import com.iterable.iterableapi.IterableInAppHandler.InAppResponse // NOTE: nested enum, not top-level + +class AppInAppHandler : IterableInAppHandler { + override fun onNewInApp(message: IterableInAppMessage): InAppResponse { + return InAppResponse.SHOW + } +} +``` + +No additional setup needed — the SDK handles display automatically. + +--- + +## Step 9: Mobile Inbox + +```kotlin +class InboxFragment : IterableInboxFragment() +``` + +That's it. Extend `IterableInboxFragment` and add it to the app's navigation. + +--- + +## Step 10: Deep Links + +### AppUrlHandler + +> **Agent note:** Replace `"yourscheme"` with the app's actual deep link scheme. Adapt the URI parsing and intent routing to match the app's screen structure. + +```kotlin +class AppUrlHandler(private val appContext: Context) : IterableUrlHandler { + override fun handleIterableURL(uri: Uri, actionContext: IterableActionContext): Boolean { + // Parse URI and route to appropriate activity/screen + if (uri.scheme == "yourscheme" && uri.host == "detail") { + val itemId = uri.pathSegments.firstOrNull()?.toIntOrNull() ?: return false + appContext.startActivity(Intent(appContext, YourTargetActivity::class.java).apply { + putExtra("item_id", itemId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }) + return true + } + return false + } +} +``` + +### AndroidManifest.xml intent filters + +> **Agent note:** Replace `"yourscheme"` with the app's deep link scheme and target the correct activity. + +```xml + + + + + + + + +``` + +### Gotcha: Custom schemes are blocked by default +The SDK only allows `https` URLs. You MUST add `.setAllowedProtocols(arrayOf("yourscheme"))` to the config builder, or custom scheme deep links will be silently dropped. + +--- + +## Step 11: Embedded Messages + +### Config +Already set in Step 2: `.setEnableEmbeddedMessaging(true)` + +### Fragment implementation + +> **Agent note:** Implement this in whichever fragment/screen should display embedded messages. Replace `PLACEMENT_ID` with the actual placement ID from the user (it is auto-generated by the Iterable dashboard — never assume a value). + +```kotlin +import com.iterable.iterableapi.IterableEmbeddedUpdateHandler // NOTE: in iterableapi package, NOT iterableapi.embedded + +class YourFragment : Fragment(), IterableEmbeddedUpdateHandler { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // MUST wait for SDK init — accessing embeddedManager before init crashes + IterableApi.onSDKInitialized { + IterableApi.getInstance().embeddedManager.addUpdateListener(this) + activity?.runOnUiThread { displayEmbeddedMessages() } + } + } + + override fun onMessagesUpdated() { + activity?.runOnUiThread { displayEmbeddedMessages() } + } + + override fun onEmbeddedMessagingDisabled() { + // Hide embedded UI + } + + private fun displayEmbeddedMessages() { + // NOTE: getMessages() returns List? (nullable). Always use isNullOrEmpty(). + val messages = IterableApi.getInstance().embeddedManager.getMessages(PLACEMENT_ID) + if (messages.isNullOrEmpty()) { + // Hide embedded UI + return + } + val message = messages.first() + // Render title, body, buttons from message.elements + // Handle button clicks via embeddedManager.handleEmbeddedClick(message, buttonId, url) + } + + override fun onDestroyView() { + try { IterableApi.getInstance().embeddedManager.removeUpdateListener(this) } + catch (_: Exception) { } + } +} +``` + +### Gotchas +- **Placement ID is auto-generated** by Iterable dashboard — never assume it's 1. Ask the user for the actual ID. +- **Accessing `embeddedManager` before SDK init throws RuntimeException** — always wrap in `onSDKInitialized`. +- **Messages sync on foreground switch** — after creating a campaign, background and foreground the app. +- **Create a Message Type for Embedded channel** in dashboard before creating templates. + +--- + +## Dashboard Prerequisites Checklist + +Before testing ANY channel, the user must configure these in Iterable: + +- [ ] **Message Types** created for each channel (Push, In-App, Embedded) +- [ ] **Firebase service account JSON** uploaded for push +- [ ] **Push integration name** matches code exactly +- [ ] **Embedded placement** created (note the auto-generated ID) +- [ ] **GCP IAM role**: "Firebase Cloud Messaging API Admin" (not similarly named roles) + +--- + +## Top 12 Traps (ordered by severity) + +1. **JWT-required key with no auth handler** → ALL SDK calls silently fail, zero errors +2. **JWT secret encoding** → use raw UTF-8, not hex-decode, not base64-decode +3. **Caching email at startup for the auth handler** → `AUTH_TOKEN_NULL`. The auth handler lambda must read the email fresh from the source of truth (DataStore, SharedPreferences, etc.) every time `onAuthTokenRequested()` is called +4. **Calling setEmail in Application.onCreate init callback** → poisons SDK auth retry state +5. **Missing runtime POST_NOTIFICATIONS permission** → push silently fails on Android 13+. Must use `ActivityResultContracts.RequestPermission()` in the main activity +6. **Custom URL schemes silently blocked** → must use `setAllowedProtocols` +7. **`onTokenRegistrationSuccessful` is local, not server** → don't trust it +8. **Firing setEmail + updateUser + registerForPush simultaneously** → auth race condition. Use `setEmail(email, onSuccess, onFailure)` and chain `updateUser` in `onSuccess` +9. **Accessing embeddedManager before SDK init** → crash +10. **Placement IDs are auto-generated** → never hardcode assumed values +11. **Message Types must exist before templates** → dashboard prerequisite for every channel +12. **Don't hallucinate Iterable dashboard paths** → defer to user or official docs