> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gameball.co/llms.txt
> Use this file to discover all available pages before exploring further.

# How Do I Set Up In-App Messaging in the Android SDK (v4)?

> Display Gameball in-app campaigns in your Android app and control when and how they appear

> [!NOTE]
>
> **Beta.** In-App Messaging is in beta and open to every Gameball account. Send questions and feedback to [support@gameball.co](mailto:support@gameball.co).

# Display In-App Messages

One call after you identify the customer, and the Gameball SDK draws your dashboard campaigns over the current Activity: slide-ups, modals, and full screens, triggered by session start or by the events you already send. In-app messaging needs **Gameball Android SDK 4.0.0** or later. It is entirely opt-in: until you call it, the module makes no requests, registers no callbacks, stores nothing, and draws nothing.

For campaign options, triggers, filters, priority, cooldown, quiet hours, and how the analytics are defined, see [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md).

***

## Requirements

| **Item** | Requirement |
| - | - |
| SDK | `com.github.gameballers:gb-mobile-android` 4.0.0 or later |
| Android | minSdk 24 (Android 7.0), compileSdk 34, Kotlin 2.0+, AndroidX, Java/Kotlin target 17 |
| Permissions | `INTERNET` only, declared by the SDK. In-app messaging adds no manifest entries or permissions |
| Dependencies | None beyond the SDK's existing ones. Artwork loads through Picasso, and the module uses Kotlin coroutines, AppCompat, and Material |
| Activity | Messages are drawn into the resumed Activity's content view. No Activity or Application parameter is needed |
| Navigation | An `onNavigate` hook if your campaigns use `navigate` actions |

***

## Install

Add the repositories to your project-level `settings.gradle`, and the dependency to your app-level `build.gradle`:

```groovy settings.gradle theme={null}
dependencyResolutionManagement {
  repositories {
    google()
    mavenCentral()
    maven { url "https://jitpack.io" }
    maven { url 'https://developer.huawei.com/repo/' }
  }
}
```

```groovy build.gradle theme={null}
dependencies {
  implementation 'com.github.gameballers:gb-mobile-android:4.0.0'
}
```

For older Gradle setups, see [Getting Started](https://docs.gameball.co/installation-guides/v4/android/getting-started.md).

> [!WARNING]
>
> **Upgrading from 3.x?** SDK 4.0.0 raises the minimum SDK version to 24 (Android 7.0), so set `minSdk` to 24 or higher in your app module. Every 3.x call keeps its signature and behavior. See [Migration Notes](https://docs.gameball.co/installation-guides/v4/android/migration-notes.md).

Initialize the SDK in your `Application` and identify the customer as you do today. Both must happen before messaging starts.

```kotlin theme={null}
val config = GameballConfig.builder()
    .apiKey("your-api-key")
    .lang("en")
    .platform("android")
    .build()
GameballApp.getInstance(this).init(config)

val customerRequest = InitializeCustomerRequest.builder()
    .customerId("customer-123")
    .build()
GameballApp.getInstance(this).initializeCustomer(customerRequest, callback)
```

***

## Start Messaging

Call it once you know who the customer is, typically right after `initializeCustomer`. Messages then appear on session start and when you send matching events.

```kotlin Kotlin theme={null}
val gameball = GameballApp.getInstance(context)
gameball.startInAppMessaging("customer-123")
```

```java Java theme={null}
GameballApp gameball = GameballApp.getInstance(context);
gameball.startInAppMessaging("customer-123");
```

| **Parameter** | Type | Default | Meaning |
| - | - | - | - |
| `customerId` | `String` | Required | Whose campaigns to sync and to whom analytics are attributed. Starting again with a different ID refetches campaigns and resets frequency history for the new customer. Starting again with the same ID keeps the running session and replaces your hooks. A blank ID is ignored with a log line. |
| `options` | `InAppMessagingOptions?` | `null` | Hooks and settings, built with `InAppMessagingOptions.builder()`. `sessionTimeoutSeconds` and `appVersion` keep their values from the first start until you call `stopInAppMessaging()`. |

> [!NOTE]
>
> **Start early.** The SDK learns which Activity to draw into from lifecycle callbacks it registers when you start messaging. If you start after the current Activity has resumed, for example from a login callback, the first message waits until an Activity next resumes, and the log shows `no Activity available; campaign N deferred`. When the customer is already known at launch, start messaging in `Application.onCreate`.

`stopInAppMessaging()` dismisses anything on screen, clears stored personalization values, sends pending analytics (waiting at most 800 ms), and unregisters the lifecycle callbacks. Call it on logout. Cached campaigns and frequency history stay on the device for that customer: the same customer picks them up after signing back in, and the next customer to start discards them. Analytics not sent in time go out after messaging starts again. Calling it when messaging was never started is safe, and `isInAppMessagingStarted()` reports the state.

> [!NOTE]
>
> Identifying a different customer through `initializeCustomer` while messaging runs switches the module to that customer automatically, and keeps your options.

***

## Control Display and Actions

Every hook is optional and individually guarded. If yours throws, you lose the override for that call, never the message. Hooks run on the main thread, and `beforeDisplay` is synchronous, so keep them fast.

```kotlin Kotlin theme={null}
val options = InAppMessagingOptions.builder()
    // Hold a message back, or drop it for this trigger.
    .beforeDisplay { message ->
        if (checkoutInProgress) DisplayDecision.DISCARD else DisplayDecision.SHOW
    }
    // Handle a tap yourself. Return true and the SDK only closes the message;
    // the click is reported to Gameball either way.
    .onAction { message, button, action ->
        when (action) {
            is GameballMessageAction.OpenUrl -> myRouter.open(action.url)
            else -> false
        }
    }
    // Route a campaign's navigate action into your own navigation.
    .onNavigate { route, arguments -> navController.navigate(route) }
    // See every message the SDK selects, whatever happens to it next.
    .observer { message -> analytics.log("gb_message_selected", message.campaignId) }
    .sessionTimeoutSeconds(30)
    .appVersion(BuildConfig.VERSION_NAME)
    .build()

gameball.startInAppMessaging("customer-123", options)
```

```java Java theme={null}
InAppMessagingOptions options = InAppMessagingOptions.builder()
    .onNavigate((route, arguments) -> navController.navigate(route))
    .build();
gameball.startInAppMessaging("customer-123", options);
```

| **Builder method** | Signature | Meaning |
| - | - | - |
| `beforeDisplay` | `(InAppMessage) -> DisplayDecision` | `SHOW` displays now. `LATER` parks the message in the pending slot, and the SDK shows it at the next retry without calling `beforeDisplay` again. `DISCARD` drops it for this occurrence. |
| `onAction` | `(InAppMessage, InAppMessageButton?, GameballMessageAction) -> Boolean` | Return `true` when you handled the tap, `false` for built-in handling. The SDK reports the click whatever you return and closes the message once your hook returns. `button` is `null` when the message itself was tapped. Closing a message with the close button, a swipe, Back, or auto-dismiss does not call it. |
| `onNavigate` | `(String, Map<String, Any?>?) -> Unit` | Receives `navigate` actions after the message closes. The route is a bare name with no leading slash. Without it, the SDK logs a warning and does nothing. |
| `observer` | `(InAppMessage) -> Unit` | Called for every selected message before `beforeDisplay`, so it also sees messages you then defer or discard. |
| `sessionTimeoutSeconds` | `Int`, default 30 | Background time after which a resume counts as a new session. Keep it at or above your account cooldown, or the cooldown can suppress a session-start message. |
| `appVersion` | `String?`, default `null` | Reported on each sync so campaigns can target app versions. The SDK does not fill it in; pass `BuildConfig.VERSION_NAME` to report it. |

> [!TIP]
>
> To keep messages off screen for a whole flow such as checkout, return `DISCARD`. A message you defer with `LATER` shows at the next retry, for example when an Activity resumes, even if the flow is still in progress.

`InAppMessage` exposes:

* `campaignId` (`Int`) and `variationId` (`Int?`)
* `name` (`String?`): the dashboard name, for logs only
* `messageType` (`Int`): `1` slide-up, `2` modal, `3` full screen
* `header` and `body` (`String?`)
* `buttons` (`List<InAppMessageButton>`): always empty for a slide-up
* `isTest` (`Boolean`): `true` for a dashboard test send, which displays normally and reports no analytics but still counts toward this device's frequency history

`header`, `body`, and button text are the campaign's copy before personalization, so they can contain `{token}` placeholders. Styling is applied by the SDK and is not exposed to the host.

***

## Actions

`GameballMessageAction` is a sealed class, so an exhaustive `when` is safe. A tap on the message itself does nothing unless the campaign sets a click action, and a button with no action closes the message.

| **Type** | Fields | Built-in behavior |
| - | - | - |
| `Dismiss` | None | Closes the message. |
| `OpenUrl` | `url`, `external` | Fires `ACTION_VIEW` for the URL, so it opens in the browser or in an app that handles the link, including yours. Built-in handling ignores `external`; read it in `onAction` if you route links yourself. |
| `Navigate` | `route`, `arguments` | Handed to your `onNavigate` hook after the message closes. |

A campaign action this version does not support, such as **request push permission**, reaches your hook as `Dismiss` and closes the message.

When the SDK runs a `Navigate` action itself, it first sends pending analytics, waiting at most 800 ms, and then calls `onNavigate`. A built-in `OpenUrl` opens immediately, and the same flush runs alongside it. Analytics are stored on the device either way, so a click that is not sent in time goes out with a later batch.

***

## Events and Purchases

Events you already send through `sendEvent` reach the trigger engine automatically, metadata included, so campaign filters work on them. The event name must match the campaign's event name exactly, including letter case. A filter on a property the event does not carry fails, and number comparisons accept numbers or numeric strings, so send numbers as numbers where a campaign compares them.

```kotlin theme={null}
gameball.sendEvent(
    Event.builder()
        .customerId("customer-123")
        .eventName("place_order")
        .eventMetaData("total", 250)
        .build(),
    callback
)
```

`logPurchase` sends Gameball an event named `purchase`, with `productId`, `price`, `currency`, and `quantity` as metadata, whether or not messaging is running. Purchase-triggered campaigns can filter on those fields and on anything you pass in `properties`. If you already send a `purchase` event through `sendEvent`, replace it with `logPurchase` rather than sending both.

```kotlin Kotlin theme={null}
gameball.logPurchase(
    customerId = "customer-123",
    productId = "sku-42",
    price = 250.0,
    currency = "USD",
    quantity = 1
)
```

```java Java theme={null}
gameball.logPurchase("customer-123", "sku-42", 250.0, "USD");
```

A blank `customerId` is reported to `callback` as an error, and nothing is sent. `callback` and `sessionToken` behave as they do on `sendEvent`.

***

## Language

There is no separate language setting for messages. Each sync requests the customer's preferred language when your app set one, otherwise the `lang` you passed to `init`, and an unsupported code falls back to `en`. If you change the language with `setLanguage`, messages follow it from the next sync. Message copy comes from the campaign; the SDK's only built-in text, the close button's accessibility label, ships in English and Arabic.

Layouts use start and end alignment, so they mirror when your Activity's layout direction is right-to-left. The SDK does not change direction based on the message language.

> [!WARNING]
>
> An SDK cannot force `android:supportsRtl`. Declare it in your own manifest if you serve right-to-left languages, or the layout will not mirror.

***

## Personalization

Campaign text can contain placeholders such as `{player_name}` and `{points_balance}` in the header, body, and button labels. See [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md) for the list. Just before a message with a placeholder shows, the SDK fetches the customer's current values, waiting at most 2 seconds, and reuses them for up to 60 seconds. Every event or purchase you send clears that cache, so a points balance reflects the action that triggered the message. The SDK removes any placeholder it cannot fill, and the message still shows.

Your hooks receive the text before substitution.

***

## Push Permission

In-app messages cannot request notification permission in this version. A campaign button set to **request push permission** closes the message, and the SDK logs `action type 'request_push_permission' is not implemented`.

> [!TIP]
>
> To ask for notification permission from a message, give the button a `navigate` action and request `POST_NOTIFICATIONS` in your `onNavigate` handler.

***

## Behavior Reference

The campaign rules themselves are described in [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md). This is what they mean in an Android app.

| **Topic** | In this SDK |
| - | - |
| Sessions | Foreground and background are derived from the count of started Activities, so navigation and rotation never look like a session break. A resume after more than the session timeout starts a new session and re-syncs. |
| Sync and cache | Campaigns and account settings are fetched at session start and cached. The cache is read only when the sync fails. Storage is customer-scoped. |
| Message types | A slide-up sits at the top or bottom edge, leaves your app usable, and carries no buttons. Swiping it toward its own edge closes it, and it closes itself after 8 seconds unless the campaign sets another time. A modal is a card over a dimmed background with up to two buttons. A full screen is opaque, with stacked full-width buttons. Campaign types this version cannot draw are skipped, so a lower-priority campaign can show instead. |
| Where it draws | Into the resumed Activity's `android.R.id.content`, resolved fresh at each presentation. No overlay permission is used. A full screen is opaque and fills that view. On an edge-to-edge host, its background and image-only artwork run behind the system bars while text, buttons, and the close button stay clear of them. On any other host, it sits below the status bar. |
| Back button | Modal and full screen consume the system Back button: Back closes the message and does not reach your Activity. On a `ComponentActivity` this goes through `OnBackPressedDispatcher` and works with predictive back. On any other Activity the SDK falls back to a key listener, which stops working once you opt in to predictive back. A slide-up does not intercept Back. |
| Theme | Messages inflate under the SDK's own Material theme wrapper, so a non-Material host theme cannot crash them. Colors a campaign leaves unset follow your theme. |
| One at a time | One message on screen and a single pending slot, held in memory. A message that cannot show yet, because you returned `LATER`, the profile widget is open, no Activity is resumed, or a full screen is waiting for its orientation, is retried when an Activity resumes (including after rotation or a return to the foreground) and when the widget closes. The retry rechecks expiry, frequency, artwork, quiet hours, and cooldown, and drops the message if it no longer qualifies. A newer deferral replaces an older one. |
| Profile widget | Nothing draws over the Gameball profile widget. A message selected while it is open waits and is retried when it closes. |
| Priority | Each trigger shows at most one campaign: the highest priority wins, and ties go to the order set in the dashboard. Other eligible campaigns for that trigger are not kept. |
| Cooldown and quiet hours | Both come from the account settings. The cooldown is the minimum time between any two messages, measured from the last impression, and defaults to 30 seconds. Quiet hours are compared against UTC, not the device's local time. A trigger that falls inside quiet hours or the cooldown is dropped, not deferred. |
| Orientation | A full screen authored for portrait or landscape logs *deferred until rotation* and displays when an Activity next resumes in a matching orientation. If your Activity handles orientation changes itself with `android:configChanges`, it is not recreated on rotation, so the message waits until an Activity next resumes. |
| Artwork | Prefetched at sync with a 5-second budget. A campaign whose image or icon has not loaded is skipped, and a lower-priority campaign can take the slot. Failed URLs are retried at most every 30 seconds, when a trigger fires. `http://` artwork gets a warning in the log and fails wherever cleartext traffic is blocked, so serve artwork over HTTPS. |
| Impressions | Recorded on the first pre-draw pass, which is also when auto-dismiss starts. A message closed before it draws is not counted. |
| Analytics | Impression, click (with the button ID, and the URL for `OpenUrl`), and dismiss, each with a stable event ID. A dismissal that follows a tap is not counted. Sent every 30 seconds, at 10 queued events, and when the app goes to the background, 50 events per request. The queue is stored on the device after every change and keeps at most 500 events. Test sends report nothing. |
| Accessibility | All text is in sp, slide-up copy is clamped to three lines, long modal copy scrolls inside the card with the buttons reachable, and animator scale 0 disables entry animations. |
| GIF artwork | Shows its first frame only in this version. |

***

## Logging and Troubleshooting

The SDK logs its decisions under the tag `GameballIAM`. Logging is on in debuggable builds and off in release builds, and nothing from this log leaves the device. To turn it on in a release build while you reproduce a problem, set it before or after you start messaging. The setting lasts until the app process ends.

```kotlin Kotlin theme={null}
GameballApp.inAppMessagingLogging = true
```

```java Java theme={null}
GameballApp.setInAppMessagingLogging(true);
```

```bash theme={null}
adb logcat -s GameballIAM
```

Errors thrown while starting or stopping messaging are logged under the `GameballApp` tag instead.

| **Log line** | Meaning |
| - | - |
| `parsed X/Y campaigns, cooldown Ns` | The session sync succeeded. X of the Y campaigns returned can be shown on this device. |
| `sync failed (<reason>); falling back to the cache` | The sync failed, so the last cached campaigns for this customer are in use. When the device is offline, `sync could not reach the backend: …` comes first. |
| `sync failed (401 - the API key was rejected); …` | Check the API key you pass to `init`. |
| `sync failed (404 - the backend does not know this customer yet); …` | The customer does not exist in Gameball yet. Start messaging after `initializeCustomer` succeeds. |
| `sync failed (422 - the customer is deactivated); …` | The customer is deactivated. |
| `no Activity available; campaign N deferred` | No resumed Activity was known when the message was ready. It shows when an Activity next resumes. |
| `campaign N deferred: <reason>` | The message waits in the pending slot. The reason is `the host asked to defer it`, `the host's widget is open`, `another message is already showing`, or `no surface was available`. |
| `campaign N wants PORTRAIT; deferred until rotation` | A full screen is waiting for a matching orientation (`PORTRAIT` or `LANDSCAPE`). |
| `pending campaign N is no longer eligible; dropped` | At retry, the waiting message failed a rule such as the cooldown or quiet hours. |
| `inside the Ns display floor; nothing selected` | The account cooldown has not passed since the last message. |
| `inside quiet hours; suppressing N eligible campaign(s)` | The account's quiet hours, compared in UTC, cover the current time. |
| `artwork failed to load: <url> (<error>)` | The image did not load, so the campaign is skipped until a retry succeeds. |
| `artwork is served over cleartext http and will fail on API 28+: <url> - the campaign using it will never display` | The campaign's image is not HTTPS. Fix it in the dashboard. |
| `campaign wants route '<route>' but no navigator is set` | Add `onNavigate` to your options. |
| `action type '<type>' is not implemented` | The campaign uses an action this SDK version does not support, such as `request_push_permission`. |

> [!NOTE]
>
> **Nothing shows at all?** Check in order:
>
> * Logging is on. It is off in release builds unless you set `GameballApp.inAppMessagingLogging = true`.
> * `init` ran with the API key, and the customer is initialized.
> * `startInAppMessaging` was called with a non-blank ID.
> * `parsed X/Y campaigns` shows a usable campaign, and no `sync failed` line appears.
> * The campaign is live, targets Android, and the customer is in its audience.
> * Its artwork loads over HTTPS.
> * The account cooldown or quiet hours are not suppressing it.
> * The customer has not already seen it.
>
> A fresh customer ID resets frequency history while you test.

***

## ProGuard and R8

If you minify, keep the SDK and Gson as the SDK's README already recommends:

```proguard proguard-rules.pro theme={null}
-keep class com.gameball.gameball.** { *; }
-keep class com.gameball.gameball.model.** { *; }
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.google.gson.** { *; }
```

***

## API Reference

| **Member** | Signature |
| - | - |
| Start | `fun startInAppMessaging(customerId: String, options: InAppMessagingOptions? = null)` |
| Stop | `fun stopInAppMessaging()` |
| State | `fun isInAppMessagingStarted(): Boolean` |
| Options | `InAppMessagingOptions.builder()` with `sessionTimeoutSeconds`, `beforeDisplay`, `onAction`, `onNavigate`, `observer`, `appVersion` |
| Hooks | `fun interface BeforeDisplayHook`, `MessageActionHook`, `MessageNavigationHook`, `MessageObserver` |
| Decision | `enum class DisplayDecision { SHOW, LATER, DISCARD }` |
| Actions | `sealed class GameballMessageAction`: `Dismiss`, `OpenUrl(url, external)`, `Navigate(route, arguments)` |
| Message model | `data class InAppMessage(campaignId: Int, variationId: Int?, name: String?, messageType: Int, header: String?, body: String?, buttons: List<InAppMessageButton>, isTest: Boolean)` |
| Button model | `data class InAppMessageButton(id: String, text: String)` |
| Purchase | `fun logPurchase(customerId: String, productId: String, price: Double, currency: String, quantity: Int = 1, properties: Map<String, Any>? = null, callback: Callback<Boolean>? = null, sessionToken: String? = null)`, with `@JvmOverloads` |
| Logging | `GameballApp.inAppMessagingLogging: Boolean` (static; Java `GameballApp.setInAppMessagingLogging(boolean)` and `getInAppMessagingLogging()`) |

***

## Launch Checklist

**1. Upgrade the SDK**

`com.github.gameballers:gb-mobile-android:4.0.0` and `minSdk` 24 or higher.

**2. Initialize and identify**

`init` with the production API key in `Application.onCreate`, then `initializeCustomer`.

**3. Start and stop messaging**

`startInAppMessaging(customerId, options)` as early as the customer is known, and `stopInAppMessaging()` on logout.

**4. Wire navigation**

`onNavigate` wired if any campaign uses `navigate`.

**5. Declare RTL support**

`android:supportsRtl="true"` if you serve right-to-left languages.

**6. Add keep rules and test**

ProGuard keep rules in place for release builds, then run one campaign end to end on a fresh customer ID with `adb logcat -s GameballIAM` open, in a debuggable build or with `GameballApp.inAppMessagingLogging = true`.

***

## Related Articles

> [card] [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md)
>
> Campaign options, triggers, delivery rules, and analytics definitions.

> [card] [Track Events](https://docs.gameball.co/installation-guides/v4/android/track-events.md)
>
> Send the events your campaigns are triggered by.

> [card] [Push Notifications](https://docs.gameball.co/installation-guides/v4/android/push-notifications.md)
>
> Reach customers when the app is closed.

> [card] [Go-Live Checklist](https://docs.gameball.co/installation-guides/v4/android/go-live-checklist.md)
>
> Verify the full Android integration before release.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.