DEV Community

Behzod Halil
Behzod Halil

Posted on Originally published at getstockplus.app

Shipping OneSignal push in a Kotlin Multiplatform app

Originally published on getstockplus.app.

Most of a push-notification implementation has nothing to do with the product. Token tables, refresh callbacks, stale-token pruning, a separate iOS pipeline: none of it is the feature, and all of it needs maintaining.

StockPlus is a Kotlin Multiplatform app whose core product is price alerts, delivered as push notifications. This is how we moved it from direct Firebase Cloud Messaging to OneSignal: what actually changed architecturally, and the four silent failures that had to be hunted down along the way.

Addressing users, not devices

Direct FCM builds a message around a registration token. One token, one device. That sounds simple, but it quietly generates a lot of server-side work: a table of tokens per user, a refresh callback because tokens rotate, pruning on UNREGISTERED because they go stale, a hand-written fan-out loop because a phone and a tablet are two rows, and an entirely separate iOS pipeline with separate credentials.

OneSignal inverts the unit of addressing. The client declares an identity:

OneSignal.login(userId)
Enter fullscreen mode Exit fullscreen mode

The server then addresses that identity instead of a device:

mapOf(
    "app_id" to appId,
    "target_channel" to "push",
    "include_aliases" to mapOf("external_id" to listOf(userId)),
    "headings" to mapOf("en" to title),
    "contents" to mapOf("en" to body),
    "data" to data,
)
Enter fullscreen mode Exit fullscreen mode

One HTTP call reaches every device where that user is logged in, on both platforms, and the OneSignal path stores no device tokens of its own. (The legacy users.fcm_token column is still there, still feeding the old channel described below.) The real change is the unit of addressing, not the vendor. The migration removed more lines than it added, which is usually a good sign: deleted code has no bugs and needs no tests.

No flag day

The pipeline was already live and price alerts are the product, so a big-bang cutover was out. The new channel went in beside the old one, selected purely by configuration:

val isEnabled: Boolean
    get() = appId.isNotBlank() && restApiKey.isNotBlank()
Enter fullscreen mode Exit fullscreen mode
external:
  onesignal:
    app-id: ${ONESIGNAL_APP_ID:}       # unset => legacy FCM path
    rest-api-key: ${ONESIGNAL_REST_API_KEY:}
Enter fullscreen mode Exit fullscreen mode

Deploying the code is not the cutover: the binary behaves exactly as before until the credentials are set. Rollback is an environment variable rather than a revert. Local dev and CI have no credentials, so they transparently use the legacy path and nothing ever accidentally sends from a laptop. Note which way the default points: doing nothing gets you the old, proven behaviour.

The legacy path has to stay fully functional: retries, backoff, stale-token pruning, all of it. A fallback that has quietly rotted is not a fallback.

Durable first, push second

This is the design decision worth defending hardest, and it applies whichever vendor you pick: a push notification is not the notification. It is an announcement that a notification exists.

Every send path persists a durable inbox row first, then attempts the push:

fun sendAlertTriggered(
    fcmToken: String?,
    ticker: String,
    alertType: AlertType,
    price: BigDecimal?,
    userId: UUID,
) {
    val (title, body) = buildAlertMessage(ticker, alertType, price)
    // Inbox is the source of truth; the push below is best-effort.
    notificationRepository.save(userId, title, body, alertType.name, ticker)

    deliverPush(fcmToken, userId, title, body, mapOf(/* ... */))
}
Enter fullscreen mode Exit fullscreen mode

Push delivery is genuinely unreliable, and not because the vendors are bad at it: denied permissions, offline devices, OS throttling, rotated tokens, guest users. On iOS, best-effort delivery is the explicit platform contract. Ordering it durable-first turns each of those failures from lost product data into a missed buzz: the alert is sitting in the inbox when the user next opens the app. It also lets the whole push layer be best-effort all the way down: no retries blocking a request, no transaction spanning an HTTP call, no error a user can ever see.

One declaration, opposite directions

Shared code depends on an expect class. Both platforms satisfy the same declaration, but they satisfy it in opposite directions, so it has to stay narrow enough that neither implementation needs to widen it:

expect class PushIdentityBinder() {
    fun login(userId: String)
    fun logout()
}
Enter fullscreen mode Exit fullscreen mode

Android is the easy one: the OneSignal SDK is a Gradle dependency, so the implementation calls it directly. It never throws (any vendor surprise degrades to "no push", never to "sign-in crashed"):

actual class PushIdentityBinder actual constructor() {
    actual fun login(userId: String) {
        runCatching { OneSignal.login(userId) }
    }

    actual fun logout() {
        runCatching { OneSignal.logout() }
    }
}
Enter fullscreen mode Exit fullscreen mode

iOS is where it gets interesting. The OneSignal iOS SDK is a Swift package, and Kotlin cannot see it: Swift sees Kotlin through the generated framework, but not the reverse. So on iOS the control flow is inverted: Kotlin holds the closures, and Swift fills them in at startup.

object IosPushIdentityBridge {
    var onLogin: ((String) -> Unit)? = null
    var onLogout: (() -> Unit)? = null
}
Enter fullscreen mode Exit fullscreen mode

One wrinkle costs a confusing hour the first time. The module holding that object is an implementation dependency of the iOS framework rather than an exported one, so its symbols never appear in the framework header and Swift cannot see the bridge at all. The fix is a thin re-export in the module that is exported:

fun setPushIdentityHandlers(
    onLogin: (String) -> Unit,
    onLogout: () -> Unit,
) {
    IosPushIdentityBridge.onLogin = onLogin
    IosPushIdentityBridge.onLogout = onLogout
}
Enter fullscreen mode Exit fullscreen mode
PushBridgeKt.setPushIdentityHandlers(
    onLogin: { userId in OneSignal.login(userId) },
    onLogout: { OneSignal.logout() }
)
Enter fullscreen mode Exit fullscreen mode

That call has to run before the root component spins up. A cold start with a saved session binds identity immediately, and getting the order wrong silently no-ops on exactly the launch that matters most: a returning, logged-in user.

Desktop binds a no-op. Three platforms, three strategies (direct call, inverted callback, deliberate nothing) behind one interface with zero conditionals in shared code.

Nothing threw, nothing was red

Push is a pipeline of best-effort steps, which means its default failure mode is silence. Four silent failures turned up.

The successful failure. OneSignal returns HTTP 200 with a populated errors field when no subscribed device matches the external id, so a naive response.isSuccessful check reports permanent success whilst delivering nothing, forever. The body has to be parsed.

The obvious parse is wrong too, and we shipped it before fixing it. Treating any non-empty errors array as failure misreads a broadcast: a chunk where one id out of five hundred is unknown lists that id under errors and still delivers to the other four hundred and ninety-nine. recipients is the field that separates partial from total failure, so errors are logged for visibility and only a zero recipient count is treated as a failure:

val json = objectMapper.readTree(responseBody)
val errors = json.path("errors")
val hasErrors = !errors.isMissingNode && errors.size() > 0
if (hasErrors) log.info("OneSignal reported errors for {}: {}", label, errors)

// An absent "recipients" falls back to the errors-only test, so a response
// shape we do not recognise is still a failure rather than silently a success.
val recipients = json.path("recipients")
val deliveredNothing =
    if (recipients.isInt) recipients.asInt() == 0 else hasErrors
Enter fullscreen mode Exit fullscreen mode

Transport success is not application success.

The misconfiguration in camouflage. The legacy path had two skip conditions with byte-identical behaviour: Firebase never initialised (someone forgot an env var), and no token on file (completely normal for guests). Both silently sent nothing. Now the first logs a warn naming the exact variable to check, and the second logs debug. When a broken configuration and a normal condition produce the same behaviour, they must not produce the same log.

The early return that only breaks one platform. This one nearly shipped:

suspend operator fun invoke(token: String? = null): AppResult<Unit> {
    // Identity binding FIRST: it needs only the userId. On iOS the FCM token
    // is always null; OneSignal is the only push channel there.
    sessionManager.currentUserId()?.let(pushIdentityBinder::login)

    val resolvedToken = token ?: pushTokenProvider.getToken()
    if (resolvedToken.isNullOrBlank()) {
        return AppResult.Error("No push token available", "NO_PUSH_TOKEN")
    }
    return pushTokenRepository.registerToken(resolvedToken, pushTokenProvider.platform)
}
Enter fullscreen mode Exit fullscreen mode

The obvious ordering (fetch the token, bail if null, then do the rest) gates identity binding behind a token that is always null on iOS. Android works perfectly; iOS never calls login(), never matches a send, and reports no error anywhere. In shared multiplatform code an early return guards everything after it on every platform, so it is worth asking whether the guard's precondition is even meaningful on all of them.

The transitive dependency. The OneSignal dashboard showed zero Android recipients whilst iOS delivered fine. The SDK's verbose logging showed FIREBASE_FCM_INIT_ERROR: the device had never subscribed at all. The dependency tree explained why: OneSignal 5.9.8 supports firebase-messaging [23.0.8, 24.0.99], but an unrelated Firestore feature pulled in the Firebase BOM, which forced 24.1.1. Gradle's conflict resolution picks the highest version, not one satisfying every constraint, so the build stayed green and the registrar died on real devices.

configurations.configureEach {
    resolutionStrategy.force("com.google.firebase:firebase-messaging:24.0.0")
}
Enter fullscreen mode Exit fullscreen mode

That block is declared in three modules (the push module itself, the shared entrypoint, and the Android application module) and the duplication is load-bearing: resolutionStrategy only governs the declaring module, and it is the application module that resolves the classpath actually shipping in the APK. Your version catalog records what you asked for; ./gradlew :entrypoint:android:dependencies records what you got.

Two traps

  • Do not declare your own MESSAGING_EVENT service. It wins over the one OneSignal merges in from its AAR, and data-only pushes get silently dropped. Our manifest carries a permanent comment saying so, because there is no lint check for code that must not exist.
  • Call OneSignal.logout() before clearing the session: it needs the outgoing access token. Reverse the order and a signed-out phone keeps receiving the previous account's price alerts. That is not a missing-notification bug, that is a data leak, one line of ordering away.

What actually mattered

The SDK calls really are two lines, and the two lines were never the work. What mattered was picking the addressing model before the vendor, making push best-effort by making something else durable first, migrating behind configuration with the safe path as the default, and hunting silent failures deliberately: verbose vendor logging on day one, parsing bodies rather than trusting status codes, and giving misconfiguration a louder log than normal operation.

Very little of this is OneSignal-specific. The durable-first contract, the config-flag migration, and the control-flow inversion apply to any platform SDK your shared Kotlin code cannot see.

The same habit turns up on the server side of this app, where jOOQ generates its code straight from the Flyway migrations. Different stack, same question: which artifact are you willing to let the build depend on, and will it tell you when it is wrong?


Written by Behzod Halil. This is the push pipeline behind StockPlus, the Kotlin Multiplatform app it was built for. The original of this post lives on getstockplus.app.

Top comments (0)