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)
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,
)
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()
external:
onesignal:
app-id: ${ONESIGNAL_APP_ID:} # unset => legacy FCM path
rest-api-key: ${ONESIGNAL_REST_API_KEY:}
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(/* ... */))
}
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()
}
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() }
}
}
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
}
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
}
PushBridgeKt.setPushIdentityHandlers(
onLogin: { userId in OneSignal.login(userId) },
onLogout: { OneSignal.logout() }
)
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
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)
}
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")
}
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_EVENTservice. 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)