Shipping OneSignal for Shipaton

Shipping OneSignal for Shipaton

Have you ever noticed how much of your push notification code has nothing to do with your product? Token tables, refresh callbacks, stale-token pruning, a separate iOS pipeline-none of it is the feature, yet all of it needs maintaining. Stock+ is a Kotlin Multiplatform app whose core product is price alerts, delivered as push notifications. This blog post describes how we migrated it from direct Firebase Cloud Messaging to OneSignal for the Shipaton hackathon: what actually changed architecturally, and the four silent failures I had to go hunting for 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 UNREGISTEREDbecause 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 server holds no device state at all.Hang on, isn’t OneSignal just a nicer FCM wrapper? I hear you ask. I thought so too, but the real change is the unit of addressing, not the vendor. The migration’s diff removed more lines than it added, which is always a good sign-deleted code doesn’t have bugs, and doesn’t need tests either. No flag day The pipeline was already live, and price alerts are the product, so I didn’t want a big-bang cutover. The new channel went in beside the old one, selected purely by configuration: val isEnabled: Boolean get() = appId.isNotBlank() && restApiKey.isNotBlank() // Yaml external: onesignal: app-id: ${ONESIGNAL_APP_ID:} # unset => legacy FCM path rest-api-key: ${ONESIGNAL_REST_API_KEY:} This gives us some nice properties. 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. And note which way the default points: doing nothing gets you the old, proven behaviour.One thing to be careful of: the legacy path needs to stay fully functional-retries, backoff, stale-token pruning, all of it. A fallback which has quietly rotted isn’t really a fallback at all. Durable first, push second Here’s the design decision I’d defend hardest, and it applies whichever vendor you pick: a push notification is not the notification. It’s 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 interface, opposite directions Shared code depends on a plain interface. You might reach for expect class here, but whilst that works, a plain interface does the same job and stays mockable in tests for free: interface 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.Note that it never throws-any vendor surprise degrades to “no push”, never to “sign-in crashed”: class AndroidPushIdentityBinder : PushIdentityBinder { override fun login(userId: String) { runCatching { OneSignal.login(userId) } } override 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 we invert control: Kotlin holds closures, and Swift fills them in at startup: object IosPushIdentityBridge { var onLogin: ((String) -> Unit)? = null var onLogout: (() -> Unit)? = null } // iOS IosPushIdentityBridge.shared.onLogin = { OneSignal.login(externalId: $0) } IosPushIdentityBridge.shared.onLogout = { OneSignal.logout() } One wrinkle cost me a confusing hour: shared is an implementation dependency of the iOS framework, not exported, so its symbols don’t appear in the framework header at all. Swift literally cannot see the bridge. The fix is a thin re-export in the exported module, called from AppDelegate 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 simply 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. I went hunting and found four silent failures. 😅 The successful failure. OneSignal returns HTTP 200 with an errors field when no subscribed device matches the external id, so a naive response.isSuccessful check reports permanent success whilst delivering nothing, forever. The fix is to parse the body: val errors = objectMapper.readTree(responseBody).path("errors") if (!errors.isMissingNode && errors.size() > 0) { log.info("OneSignal delivered nothing for userId={}: {}", userId, errors) } 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 mustn’t produce the same log. The early return that only breaks one platform. This one nearly shipped, and I’ll admit it’s my favourite: suspend operator fun invoke(token: String? = null): Result { // 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 Result.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 which 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’s 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. Turning on the SDK’s verbose logging showed FIREBASE_FCM_INIT_ERROR-the device had never subscribed at all. Running ./gradlew:composeApp:dependencies 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 lives in both shared/ and composeApp/, and the duplication is load-bearing: resolutionStrategy only governs the declaring module, and composeApp resolves the classpath which actually ships in the APK. Your version catalog records what you asked for; the dependenciestask records what you got.Two traps Don’t declare your own MESSAGING_EVENTservice-it wins over the one OneSignal merges in from its AAR, and data-only pushes get silently dropped. My manifest carries a permanent comment saying so, because there’s no lint check for code which mustn’t exist. And 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’s not a missing-notification bug; that’s a data leak, one line of ordering away.

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.