<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Behzod Halil]]></title><description><![CDATA[Behzod Halil]]></description><link>https://behzodhalil.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Behzod Halil</title><link>https://behzodhalil.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 30 Aug 2026 18:54:55 GMT</lastBuildDate><atom:link href="https://behzodhalil.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Shipping OneSignal push in a Kotlin Multiplatform app]]></title><description><![CDATA[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: ]]></description><link>https://behzodhalil.hashnode.dev/shipping-onesignal-push-in-a-kotlin-multiplatform-app</link><guid isPermaLink="true">https://behzodhalil.hashnode.dev/shipping-onesignal-push-in-a-kotlin-multiplatform-app</guid><category><![CDATA[Kotlin]]></category><category><![CDATA[Kotlin Multiplatform]]></category><category><![CDATA[Android]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Behzod Halil]]></dc:creator><pubDate>Fri, 28 Aug 2026 18:21:20 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://getstockplus.app/blog/onesignal-kotlin-multiplatform-push">getstockplus.app</a>.</em></p>
<p>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.</p>
<p>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.</p>
<h2>Addressing users, not devices</h2>
<p>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 <code>UNREGISTERED</code> 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.</p>
<p>OneSignal inverts the unit of addressing. The client declares an identity:</p>
<pre><code class="language-kotlin">OneSignal.login(userId)
</code></pre>
<p>The server then addresses that identity instead of a device:</p>
<pre><code class="language-kotlin">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,
)
</code></pre>
<p>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 <code>users.fcm_token</code> 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.</p>
<h2>No flag day</h2>
<p>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:</p>
<pre><code class="language-kotlin">val isEnabled: Boolean
    get() = appId.isNotBlank() &amp;&amp; restApiKey.isNotBlank()
</code></pre>
<pre><code class="language-yaml">external:
  onesignal:
    app-id: ${ONESIGNAL_APP_ID:}       # unset =&gt; legacy FCM path
    rest-api-key: ${ONESIGNAL_REST_API_KEY:}
</code></pre>
<p>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.</p>
<blockquote>
<p>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.</p>
</blockquote>
<h2>Durable first, push second</h2>
<p>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.</p>
<p>Every send path persists a durable inbox row first, then attempts the push:</p>
<pre><code class="language-kotlin">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(/* ... */))
}
</code></pre>
<p>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.</p>
<h2>One declaration, opposite directions</h2>
<p>Shared code depends on an <code>expect class</code>. 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:</p>
<pre><code class="language-kotlin">expect class PushIdentityBinder() {
    fun login(userId: String)
    fun logout()
}
</code></pre>
<p>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"):</p>
<pre><code class="language-kotlin">actual class PushIdentityBinder actual constructor() {
    actual fun login(userId: String) {
        runCatching { OneSignal.login(userId) }
    }

    actual fun logout() {
        runCatching { OneSignal.logout() }
    }
}
</code></pre>
<p>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.</p>
<pre><code class="language-kotlin">object IosPushIdentityBridge {
    var onLogin: ((String) -&gt; Unit)? = null
    var onLogout: (() -&gt; Unit)? = null
}
</code></pre>
<p>One wrinkle costs a confusing hour the first time. The module holding that object is an <code>implementation</code> dependency of the iOS framework rather than an <code>export</code>ed 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 <em>is</em> exported:</p>
<pre><code class="language-kotlin">fun setPushIdentityHandlers(
    onLogin: (String) -&gt; Unit,
    onLogout: () -&gt; Unit,
) {
    IosPushIdentityBridge.onLogin = onLogin
    IosPushIdentityBridge.onLogout = onLogout
}
</code></pre>
<pre><code class="language-swift">PushBridgeKt.setPushIdentityHandlers(
    onLogin: { userId in OneSignal.login(userId) },
    onLogout: { OneSignal.logout() }
)
</code></pre>
<p>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.</p>
<p>Desktop binds a no-op. Three platforms, three strategies (direct call, inverted callback, deliberate nothing) behind one interface with zero conditionals in shared code.</p>
<h2>Nothing threw, nothing was red</h2>
<p>Push is a pipeline of best-effort steps, which means its default failure mode is silence. Four silent failures turned up.</p>
<p><strong>The successful failure.</strong> OneSignal returns HTTP 200 with a populated <code>errors</code> field when no subscribed device matches the external id, so a naive <code>response.isSuccessful</code> check reports permanent success whilst delivering nothing, forever. The body has to be parsed.</p>
<p>The obvious parse is wrong too, and we shipped it before fixing it. Treating any non-empty <code>errors</code> array as failure misreads a broadcast: a chunk where one id out of five hundred is unknown lists that id under <code>errors</code> and still delivers to the other four hundred and ninety-nine. <code>recipients</code> 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:</p>
<pre><code class="language-kotlin">val json = objectMapper.readTree(responseBody)
val errors = json.path("errors")
val hasErrors = !errors.isMissingNode &amp;&amp; errors.size() &gt; 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
</code></pre>
<p>Transport success is not application success.</p>
<p><strong>The misconfiguration in camouflage.</strong> 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 <code>warn</code> naming the exact variable to check, and the second logs <code>debug</code>. When a broken configuration and a normal condition produce the same behaviour, they must not produce the same log.</p>
<p><strong>The early return that only breaks one platform.</strong> This one nearly shipped:</p>
<pre><code class="language-kotlin">suspend operator fun invoke(token: String? = null): AppResult&lt;Unit&gt; {
    // 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)
}
</code></pre>
<p>The obvious ordering (fetch the token, bail if null, then do the rest) gates identity binding behind a token that is <em>always</em> null on iOS. Android works perfectly; iOS never calls <code>login()</code>, 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.</p>
<p><strong>The transitive dependency.</strong> The OneSignal dashboard showed zero Android recipients whilst iOS delivered fine. The SDK's verbose logging showed <code>FIREBASE_FCM_INIT_ERROR</code>: the device had never subscribed at all. The dependency tree explained why: OneSignal 5.9.8 supports <code>firebase-messaging [23.0.8, 24.0.99]</code>, but an unrelated Firestore feature pulled in the Firebase BOM, which forced 24.1.1. Gradle's conflict resolution picks the <em>highest</em> version, not one satisfying every constraint, so the build stayed green and the registrar died on real devices.</p>
<pre><code class="language-kotlin">configurations.configureEach {
    resolutionStrategy.force("com.google.firebase:firebase-messaging:24.0.0")
}
</code></pre>
<p>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: <code>resolutionStrategy</code> 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; <code>./gradlew :entrypoint:android:dependencies</code> records what you got.</p>
<h2>Two traps</h2>
<ul>
<li>Do not declare your own <code>MESSAGING_EVENT</code> 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.</li>
<li>Call <code>OneSignal.logout()</code> <em>before</em> 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.</li>
</ul>
<h2>What actually mattered</h2>
<p>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.</p>
<p>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.</p>
<p>The same habit turns up on the server side of this app, where <a href="https://getstockplus.app/blog/jooq-codegen-from-flyway-migrations">jOOQ generates its code straight from the Flyway migrations</a>. Different stack, same question: which artifact are you willing to let the build depend on, and will it tell you when it is wrong?</p>
<hr />
<p><em>Written by Behzod Halil. This is the push pipeline behind StockPlus, the Kotlin Multiplatform app it was built for. The <a href="https://getstockplus.app/blog/onesignal-kotlin-multiplatform-push">original of this post</a> lives on getstockplus.app.</em></p>
]]></content:encoded></item></channel></rss>