
Unified API for Firebase Cloud Messaging and OneSignal push: covers permission handling, enablement, registration, token management, foreground and opened events, duplicate-open deduplication, adapters, and test fakes.
Use Firebase Cloud Messaging or OneSignal from shared Kotlin Multiplatform code without hiding the differences between them.
The shared API covers permission state, push enablement, registration, foreground notifications and notification opens. FCM uses the same client factory and callback API on Android and iOS; each app still owns its native Firebase setup.
0.1.0 is available on Maven Central. Android AARs, iOS device/simulator KLIBs and JVM artifacts are published. No repository credentials are required.
Follow the installation guide to add the library to your app, or try the credential-free sample first.
Push code tends to spread quickly: a token callback in Android, another delegate in iOS, provider state in shared code and slightly different open handling on every screen.
This project keeps that plumbing in one place while staying clear about what each provider can do. An FCM token is not a OneSignal subscription, a registration is not a delivery receipt, and receiving a notification is not the same as opening it.
FcmAndroidGateway and FcmIosGateway behind one FcmClientFactory.The library does not send notifications. It also does not provide notification UI, campaigns, analytics, topics or local notifications.
The app selects one provider module. That adapter connects the native SDK to the shared push contract.
flowchart LR
Host["Android or iOS app"] --> Provider{"Choose one provider"}
Provider --> FCM["push-fcm"]
Provider --> OneSignal["push-onesignal"]
FCM --> Core["push-core"]
OneSignal --> Core
Core --> Output["Shared state and events"]Add push-core and one provider module:
| Provider | Module | Android setup | iOS setup |
|---|---|---|---|
| Firebase Cloud Messaging | push-fcm |
google-services.json and the Google Services plugin |
GoogleService-Info.plist and FirebaseApp.configure()
|
| OneSignal | push-onesignal |
OneSignal App ID; Firebase credentials live in the OneSignal dashboard | OneSignal App ID and APNs configuration |
A OneSignal-only app does not need push-fcm or either Firebase configuration file. OneSignal still uses FCM under the hood on Google-enabled Android devices, but its SDK handles that connection. On iOS it uses APNs.
Add google() and mavenCentral() to your dependency repositories, then:
commonMain.dependencies {
implementation("io.github.sosmex.push:push-core:0.1.0")
implementation("io.github.sosmex.push:push-fcm:0.1.0")
// Or: implementation("io.github.sosmex.push:push-onesignal:0.1.0")
}Gradle selects the platform artifact from these shared coordinates. Add
io.github.sosmex.push:push-test:0.1.0 to commonTest for test fakes.
The installation guide also covers source integration,
local Maven publication and independent JVM/Android/iOS consumer checks.
Native Firebase or OneSignal setup is still required on each platform.
The app provides permission handling and durable storage for opened-event IDs. Put the client construction in shared code:
fun createFcmClient(
gateway: FcmInitializationResult<FcmGateway>,
): FcmPushClient? =
when (val result = FcmClientFactory.create(
permissionGateway = appPermissionGateway,
gateway = gateway,
ledger = appEventLedger,
)) {
is FcmInitializationResult.Ready -> result.value
is FcmInitializationResult.Unavailable -> {
showPushUnavailable(result.reason)
null
}
}Android supplies its gateway from androidMain:
val pushClient = createFcmClient(
FcmAndroidGateway.create(),
) ?: returniOS supplies its gateway from iosMain:
val pushClient = createFcmClient(
FcmIosGateway.create(
hostApi = iosFirebaseHostApi,
isDefaultFirebaseAppConfigured = firebaseAppIsConfigured,
),
) ?: returnThe Swift host configures Firebase, implements FcmIosHostApi with Messaging.messaging().isAutoInitEnabled, and passes FirebaseApp.app() != nil as firebaseAppIsConfigured.
Once the client is ready, permission, token and event handling are the same on both targets:
applicationScope.launch {
if (pushClient.requestPermission() == PermissionState.Granted) {
pushClient.setEnabled(true)
}
}
// Capture once when installing this native callback session.
val callbackGeneration = pushClient.currentGeneration()
suspend fun onNewFcmToken(token: String) {
pushClient.observeToken(
token = token,
generation = callbackGeneration,
)
}
applicationScope.launch {
pushClient.events.collect { event ->
when (event) {
is PushEvent.ForegroundReceived -> refreshContent()
is PushEvent.Opened -> openDestination(event.payload)
}
}
}Forward notification opens with offerOpened(...) and foreground data messages with offerForeground(...), using the same captured generation. Do not read currentGeneration() when a callback arrives: that would relabel an old callback as belonging to the new session. On a session change, advance the generation, replace the native callback bindings, and let already queued work retain its original generation. See callback ownership.
observeToken(...) forwards a token received from the native SDK; it does not register a listener. The observable stream is client.events.
Both platforms forward token, foreground and open callbacks directly to FcmPushClient. Only the native bootstrap differs.
val gateway = OneSignalAndroidGateway(
context = applicationContext,
appId = oneSignalAppId,
)
val pushClient = OneSignalPushClient(
permissionGateway = appPermissionGateway,
gateway = gateway,
ledger = appEventLedger,
)
applicationScope.launch {
pushClient.identity.login(accountId)
}The host app still registers OneSignal subscription, foreground and click observers and forwards those callbacks to the client. On iOS, OneSignalIosCallbackBridge forwards those callbacks, but the app must supply its own OneSignalGateway; Firebase setup is not involved.
The shared client expects three string fields:
val payload = mapOf(
"push_version" to "1",
"push_event_id" to "orders:updated:42",
"push_type" to "orders.updated",
"order_id" to "42",
)push_event_id is the deduplication key. Extra string fields are passed through as application data. Treat them as hints and fetch the latest data from your backend before acting on them.
| Module | What it contains |
|---|---|
push-core |
Shared models, state, events and deduplication contracts |
push-fcm |
FCM client factory and Android/iOS gateways |
push-onesignal |
OneSignal client, identity support, Android native gateway and iOS callback bridge |
push-test |
Fakes and an in-memory ledger for tests |
sample |
Credential-free reference flow for both providers |
The sample runs the same small flow with FCM and OneSignal: permission, enablement, registration, a foreground notification, a cold-start open and a duplicate open. It also collects client.events, which is the listener application code normally uses.
./gradlew :sample:runTo run only one provider:
./gradlew :sample:run --args='--provider=fcm'
./gradlew :sample:run --args='--provider=onesignal'It uses the real FcmPushClient and OneSignalPushClient APIs with in-memory host gateways. Native callbacks are simulated, so the sample needs no Firebase or OneSignal credentials and does not claim real notification delivery.
Set ANDROID_HOME or add an ignored local.properties with sdk.dir=..., then run:
./gradlew \
:push-core:jvmTest \
:push-fcm:jvmTest \
:push-onesignal:jvmTest \
:push-test:jvmTest \
:push-fcm:compileAndroidMain \
:push-onesignal:compileAndroidMain \
:sample:test \
:sample:runUse JDK 17 and Android SDK 36; complete Apple-target builds also require macOS and Xcode. The project currently uses Kotlin 2.4.10, Android API 36, Firebase BoM 34.18.0 and OneSignal Android 5.9.8.
CI runs the JVM, Android and iOS checks on every pull request. Publishing a semantic GitHub Release such as v0.1.0 triggers publication to the repository's private GitHub Packages registry.
Version 0.1.0 is published on Maven Central. See the release verification record for the exact commit, CI evidence and public artifact checks. The workflow stages a deployment for manual Portal review by default; requesting public release requires an explicit input. See the Central release runbook and CI/CD contract.
Apache License 2.0. See LICENSE.
Use Firebase Cloud Messaging or OneSignal from shared Kotlin Multiplatform code without hiding the differences between them.
The shared API covers permission state, push enablement, registration, foreground notifications and notification opens. FCM uses the same client factory and callback API on Android and iOS; each app still owns its native Firebase setup.
0.1.0 is available on Maven Central. Android AARs, iOS device/simulator KLIBs and JVM artifacts are published. No repository credentials are required.
Follow the installation guide to add the library to your app, or try the credential-free sample first.
Push code tends to spread quickly: a token callback in Android, another delegate in iOS, provider state in shared code and slightly different open handling on every screen.
This project keeps that plumbing in one place while staying clear about what each provider can do. An FCM token is not a OneSignal subscription, a registration is not a delivery receipt, and receiving a notification is not the same as opening it.
FcmAndroidGateway and FcmIosGateway behind one FcmClientFactory.The library does not send notifications. It also does not provide notification UI, campaigns, analytics, topics or local notifications.
The app selects one provider module. That adapter connects the native SDK to the shared push contract.
flowchart LR
Host["Android or iOS app"] --> Provider{"Choose one provider"}
Provider --> FCM["push-fcm"]
Provider --> OneSignal["push-onesignal"]
FCM --> Core["push-core"]
OneSignal --> Core
Core --> Output["Shared state and events"]Add push-core and one provider module:
| Provider | Module | Android setup | iOS setup |
|---|---|---|---|
| Firebase Cloud Messaging | push-fcm |
google-services.json and the Google Services plugin |
GoogleService-Info.plist and FirebaseApp.configure()
|
| OneSignal | push-onesignal |
OneSignal App ID; Firebase credentials live in the OneSignal dashboard | OneSignal App ID and APNs configuration |
A OneSignal-only app does not need push-fcm or either Firebase configuration file. OneSignal still uses FCM under the hood on Google-enabled Android devices, but its SDK handles that connection. On iOS it uses APNs.
Add google() and mavenCentral() to your dependency repositories, then:
commonMain.dependencies {
implementation("io.github.sosmex.push:push-core:0.1.0")
implementation("io.github.sosmex.push:push-fcm:0.1.0")
// Or: implementation("io.github.sosmex.push:push-onesignal:0.1.0")
}Gradle selects the platform artifact from these shared coordinates. Add
io.github.sosmex.push:push-test:0.1.0 to commonTest for test fakes.
The installation guide also covers source integration,
local Maven publication and independent JVM/Android/iOS consumer checks.
Native Firebase or OneSignal setup is still required on each platform.
The app provides permission handling and durable storage for opened-event IDs. Put the client construction in shared code:
fun createFcmClient(
gateway: FcmInitializationResult<FcmGateway>,
): FcmPushClient? =
when (val result = FcmClientFactory.create(
permissionGateway = appPermissionGateway,
gateway = gateway,
ledger = appEventLedger,
)) {
is FcmInitializationResult.Ready -> result.value
is FcmInitializationResult.Unavailable -> {
showPushUnavailable(result.reason)
null
}
}Android supplies its gateway from androidMain:
val pushClient = createFcmClient(
FcmAndroidGateway.create(),
) ?: returniOS supplies its gateway from iosMain:
val pushClient = createFcmClient(
FcmIosGateway.create(
hostApi = iosFirebaseHostApi,
isDefaultFirebaseAppConfigured = firebaseAppIsConfigured,
),
) ?: returnThe Swift host configures Firebase, implements FcmIosHostApi with Messaging.messaging().isAutoInitEnabled, and passes FirebaseApp.app() != nil as firebaseAppIsConfigured.
Once the client is ready, permission, token and event handling are the same on both targets:
applicationScope.launch {
if (pushClient.requestPermission() == PermissionState.Granted) {
pushClient.setEnabled(true)
}
}
// Capture once when installing this native callback session.
val callbackGeneration = pushClient.currentGeneration()
suspend fun onNewFcmToken(token: String) {
pushClient.observeToken(
token = token,
generation = callbackGeneration,
)
}
applicationScope.launch {
pushClient.events.collect { event ->
when (event) {
is PushEvent.ForegroundReceived -> refreshContent()
is PushEvent.Opened -> openDestination(event.payload)
}
}
}Forward notification opens with offerOpened(...) and foreground data messages with offerForeground(...), using the same captured generation. Do not read currentGeneration() when a callback arrives: that would relabel an old callback as belonging to the new session. On a session change, advance the generation, replace the native callback bindings, and let already queued work retain its original generation. See callback ownership.
observeToken(...) forwards a token received from the native SDK; it does not register a listener. The observable stream is client.events.
Both platforms forward token, foreground and open callbacks directly to FcmPushClient. Only the native bootstrap differs.
val gateway = OneSignalAndroidGateway(
context = applicationContext,
appId = oneSignalAppId,
)
val pushClient = OneSignalPushClient(
permissionGateway = appPermissionGateway,
gateway = gateway,
ledger = appEventLedger,
)
applicationScope.launch {
pushClient.identity.login(accountId)
}The host app still registers OneSignal subscription, foreground and click observers and forwards those callbacks to the client. On iOS, OneSignalIosCallbackBridge forwards those callbacks, but the app must supply its own OneSignalGateway; Firebase setup is not involved.
The shared client expects three string fields:
val payload = mapOf(
"push_version" to "1",
"push_event_id" to "orders:updated:42",
"push_type" to "orders.updated",
"order_id" to "42",
)push_event_id is the deduplication key. Extra string fields are passed through as application data. Treat them as hints and fetch the latest data from your backend before acting on them.
| Module | What it contains |
|---|---|
push-core |
Shared models, state, events and deduplication contracts |
push-fcm |
FCM client factory and Android/iOS gateways |
push-onesignal |
OneSignal client, identity support, Android native gateway and iOS callback bridge |
push-test |
Fakes and an in-memory ledger for tests |
sample |
Credential-free reference flow for both providers |
The sample runs the same small flow with FCM and OneSignal: permission, enablement, registration, a foreground notification, a cold-start open and a duplicate open. It also collects client.events, which is the listener application code normally uses.
./gradlew :sample:runTo run only one provider:
./gradlew :sample:run --args='--provider=fcm'
./gradlew :sample:run --args='--provider=onesignal'It uses the real FcmPushClient and OneSignalPushClient APIs with in-memory host gateways. Native callbacks are simulated, so the sample needs no Firebase or OneSignal credentials and does not claim real notification delivery.
Set ANDROID_HOME or add an ignored local.properties with sdk.dir=..., then run:
./gradlew \
:push-core:jvmTest \
:push-fcm:jvmTest \
:push-onesignal:jvmTest \
:push-test:jvmTest \
:push-fcm:compileAndroidMain \
:push-onesignal:compileAndroidMain \
:sample:test \
:sample:runUse JDK 17 and Android SDK 36; complete Apple-target builds also require macOS and Xcode. The project currently uses Kotlin 2.4.10, Android API 36, Firebase BoM 34.18.0 and OneSignal Android 5.9.8.
CI runs the JVM, Android and iOS checks on every pull request. Publishing a semantic GitHub Release such as v0.1.0 triggers publication to the repository's private GitHub Packages registry.
Version 0.1.0 is published on Maven Central. See the release verification record for the exact commit, CI evidence and public artifact checks. The workflow stages a deployment for manual Portal review by default; requesting public release requires an explicit input. See the Central release runbook and CI/CD contract.
Apache License 2.0. See LICENSE.