From f0e3578ea76f7072f83c9f9e4ab7a07f5418c9f2 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 27 Jul 2026 14:15:14 -0700 Subject: [PATCH 1/3] feat(fcm): support firebase-messaging 25.x --- .../onesignal/notifications/build.gradle | 2 +- .../notifications/consumer-rules.pro | 6 ++ .../registration/impl/PushRegistratorFCM.kt | 49 +++++++++++-- .../impl/PushRegistratorFCMTests.kt | 73 +++++++++++++++++++ 4 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt diff --git a/OneSignalSDK/onesignal/notifications/build.gradle b/OneSignalSDK/onesignal/notifications/build.gradle index be636bb3bf..07272c8d8b 100644 --- a/OneSignalSDK/onesignal/notifications/build.gradle +++ b/OneSignalSDK/onesignal/notifications/build.gradle @@ -78,7 +78,7 @@ dependencies { // compileSdkVersion 34 or higher. api('com.google.firebase:firebase-messaging') { version { - require '[23.0.8, 24.0.99]' + require '[23.0.8, 25.1.99]' prefer '24.0.0' } } diff --git a/OneSignalSDK/onesignal/notifications/consumer-rules.pro b/OneSignalSDK/onesignal/notifications/consumer-rules.pro index b7a21ca10b..78cb19b198 100644 --- a/OneSignalSDK/onesignal/notifications/consumer-rules.pro +++ b/OneSignalSDK/onesignal/notifications/consumer-rules.pro @@ -25,6 +25,12 @@ -dontwarn com.google.firebase.** -dontwarn com.google.android.gms.** +# Firebase Messaging 25.1 adds register(), which is invoked reflectively to preserve compatibility +# with earlier Firebase versions. +-keepclassmembers,allowoptimization class com.google.firebase.messaging.FirebaseMessaging { + public com.google.android.gms.tasks.Task register(); +} + # ADM handlers are instantiated by name from the app manifest AND their on* lifecycle callbacks # (onMessage/onRegistered/onRegistrationError/onUnregistered) are invoked by the ADM framework, not # the SDK, so keep both constructors and those methods. (Amazon-device-only path, untestable in CI.) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt index dd0af12514..c723f8db42 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt @@ -1,9 +1,11 @@ package com.onesignal.notifications.internal.registration.impl import android.util.Base64 +import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Tasks import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions +import com.google.firebase.installations.FirebaseInstallations import com.google.firebase.messaging.FirebaseMessaging import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore @@ -58,13 +60,15 @@ internal class PushRegistratorFCM( // as the latter uses the default Firebase app. We need to use a custom Firebase app as // the senderId is provided at runtime. val fcmInstance = firebaseApp!!.get(FirebaseMessaging::class.java) - // FirebaseMessaging.getToken API was introduced in firebase-messaging:21.0.0 - val tokenTask = fcmInstance.token - try { - return Tasks.await(tokenTask) - } catch (e: ExecutionException) { - throw tokenTask.exception ?: e - } + return FirebaseTokenProvider( + legacyTokenTask = fcmInstance.token, + // Reflection keeps firebase-messaging 23.x and 24.x binary-compatible. + registerForFid = { + @Suppress("UNCHECKED_CAST") + fcmInstance.javaClass.getMethod("register").invoke(fcmInstance) as Task + }, + installationIdTask = { FirebaseInstallations.getInstance(firebaseApp!!).id }, + ).getToken() } private fun initFirebaseApp(senderId: String) { @@ -80,3 +84,34 @@ internal class PushRegistratorFCM( firebaseApp = FirebaseApp.initializeApp(_applicationService.appContext, firebaseOptions, FCM_APP_NAME) } } + +internal class FirebaseTokenProvider( + private val legacyTokenTask: Task, + private val registerForFid: () -> Task, + private val installationIdTask: () -> Task, +) { + fun getToken(): String { + try { + return await(legacyTokenTask) + } catch (e: IllegalStateException) { + if (!e.message.orEmpty().startsWith(FID_REGISTRATION_REQUIRED_ERROR)) { + throw e + } + } + + await(registerForFid()) + return await(installationIdTask()) + } + + private fun await(task: Task): T { + try { + return Tasks.await(task) + } catch (e: ExecutionException) { + throw task.exception ?: e + } + } + + private companion object { + const val FID_REGISTRATION_REQUIRED_ERROR = "API disabled. Please use" + } +} diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt new file mode 100644 index 0000000000..7b4bf0ed8b --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt @@ -0,0 +1,73 @@ +package com.onesignal.notifications.internal.registration.impl + +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.google.android.gms.tasks.Tasks +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@RobolectricTest +class PushRegistratorFCMTests : FunSpec({ + test("returns the legacy FCM token without FID registration") { + val registerForFid = mockk<() -> com.google.android.gms.tasks.Task>() + val installationIdTask = mockk<() -> com.google.android.gms.tasks.Task>() + + val token = + withContext(Dispatchers.IO) { + FirebaseTokenProvider( + legacyTokenTask = Tasks.forResult("legacy-token"), + registerForFid = registerForFid, + installationIdTask = installationIdTask, + ).getToken() + } + + token shouldBe "legacy-token" + verify(exactly = 0) { registerForFid() } + verify(exactly = 0) { installationIdTask() } + } + + test("registers with FID and returns the installation ID when the legacy API is disabled") { + var registrationCompleted = false + + val token = + withContext(Dispatchers.IO) { + FirebaseTokenProvider( + legacyTokenTask = + Tasks.forException( + IllegalStateException("API disabled. Please use register() instead"), + ), + registerForFid = { + registrationCompleted = true + Tasks.forResult(null) + }, + installationIdTask = { + registrationCompleted shouldBe true + Tasks.forResult("installation-id") + }, + ).getToken() + } + + token shouldBe "installation-id" + } + + test("does not mask unrelated legacy token failures") { + val failure = IllegalStateException("Firebase app was deleted") + + val thrown = + shouldThrow { + withContext(Dispatchers.IO) { + FirebaseTokenProvider( + legacyTokenTask = Tasks.forException(failure), + registerForFid = { Tasks.forResult(null) }, + installationIdTask = { Tasks.forResult("installation-id") }, + ).getToken() + } + } + + thrown shouldBe failure + } +}) From f2b795dc0d0806fef9cfd4e63a332e47132b7c45 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 27 Jul 2026 14:44:05 -0700 Subject: [PATCH 2/3] feat(fcm): gate FID registration via manifest metadata --- .../registration/impl/PushRegistratorFCM.kt | 44 ++++++++++++------- .../impl/PushRegistratorFCMTests.kt | 20 ++++++--- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt index c723f8db42..ff94b7e4e9 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt @@ -1,5 +1,6 @@ package com.onesignal.notifications.internal.registration.impl +import android.content.pm.PackageManager import android.util.Base64 import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Tasks @@ -20,6 +21,7 @@ internal class PushRegistratorFCM( ) : PushRegistratorAbstractGoogle(deviceService, _configModelStore, upgradePrompt) { companion object { private const val FCM_APP_NAME = "ONESIGNAL_SDK_FCM_APP_NAME" + private const val FID_REGISTRATION_ENABLED = "firebase_messaging_installation_id_enabled" // project_info.project_id private const val FCM_DEFAULT_PROJECT_ID = "onesignal-shared-public" @@ -60,17 +62,36 @@ internal class PushRegistratorFCM( // as the latter uses the default Firebase app. We need to use a custom Firebase app as // the senderId is provided at runtime. val fcmInstance = firebaseApp!!.get(FirebaseMessaging::class.java) + val registerMethod = + fcmInstance.javaClass.methods.firstOrNull { + it.name == "register" && it.parameterTypes.isEmpty() + } return FirebaseTokenProvider( - legacyTokenTask = fcmInstance.token, + fidRegistrationEnabled = registerMethod != null && isFidRegistrationEnabled(), + legacyTokenTask = { fcmInstance.token }, // Reflection keeps firebase-messaging 23.x and 24.x binary-compatible. registerForFid = { @Suppress("UNCHECKED_CAST") - fcmInstance.javaClass.getMethod("register").invoke(fcmInstance) as Task + registerMethod!!.invoke(fcmInstance) as Task }, installationIdTask = { FirebaseInstallations.getInstance(firebaseApp!!).id }, ).getToken() } + private fun isFidRegistrationEnabled(): Boolean { + val context = _applicationService.appContext + return try { + val applicationInfo = + context.packageManager.getApplicationInfo( + context.packageName, + PackageManager.GET_META_DATA, + ) + applicationInfo.metaData?.getBoolean(FID_REGISTRATION_ENABLED, false) ?: false + } catch (_: PackageManager.NameNotFoundException) { + false + } + } + private fun initFirebaseApp(senderId: String) { if (firebaseApp != null) return val firebaseOptions = @@ -86,21 +107,18 @@ internal class PushRegistratorFCM( } internal class FirebaseTokenProvider( - private val legacyTokenTask: Task, + private val fidRegistrationEnabled: Boolean, + private val legacyTokenTask: () -> Task, private val registerForFid: () -> Task, private val installationIdTask: () -> Task, ) { fun getToken(): String { - try { - return await(legacyTokenTask) - } catch (e: IllegalStateException) { - if (!e.message.orEmpty().startsWith(FID_REGISTRATION_REQUIRED_ERROR)) { - throw e - } + if (fidRegistrationEnabled) { + await(registerForFid()) + return await(installationIdTask()) } - await(registerForFid()) - return await(installationIdTask()) + return await(legacyTokenTask()) } private fun await(task: Task): T { @@ -110,8 +128,4 @@ internal class FirebaseTokenProvider( throw task.exception ?: e } } - - private companion object { - const val FID_REGISTRATION_REQUIRED_ERROR = "API disabled. Please use" - } } diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt index 7b4bf0ed8b..f3eee8ae77 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt @@ -5,6 +5,7 @@ import com.google.android.gms.tasks.Tasks import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers @@ -13,33 +14,36 @@ import kotlinx.coroutines.withContext @RobolectricTest class PushRegistratorFCMTests : FunSpec({ test("returns the legacy FCM token without FID registration") { + val legacyTokenTask = mockk<() -> com.google.android.gms.tasks.Task>() val registerForFid = mockk<() -> com.google.android.gms.tasks.Task>() val installationIdTask = mockk<() -> com.google.android.gms.tasks.Task>() + every { legacyTokenTask() } returns Tasks.forResult("legacy-token") val token = withContext(Dispatchers.IO) { FirebaseTokenProvider( - legacyTokenTask = Tasks.forResult("legacy-token"), + fidRegistrationEnabled = false, + legacyTokenTask = legacyTokenTask, registerForFid = registerForFid, installationIdTask = installationIdTask, ).getToken() } token shouldBe "legacy-token" + verify(exactly = 1) { legacyTokenTask() } verify(exactly = 0) { registerForFid() } verify(exactly = 0) { installationIdTask() } } - test("registers with FID and returns the installation ID when the legacy API is disabled") { + test("registers with FID without calling the disabled legacy API") { var registrationCompleted = false + val legacyTokenTask = mockk<() -> com.google.android.gms.tasks.Task>() val token = withContext(Dispatchers.IO) { FirebaseTokenProvider( - legacyTokenTask = - Tasks.forException( - IllegalStateException("API disabled. Please use register() instead"), - ), + fidRegistrationEnabled = true, + legacyTokenTask = legacyTokenTask, registerForFid = { registrationCompleted = true Tasks.forResult(null) @@ -52,6 +56,7 @@ class PushRegistratorFCMTests : FunSpec({ } token shouldBe "installation-id" + verify(exactly = 0) { legacyTokenTask() } } test("does not mask unrelated legacy token failures") { @@ -61,7 +66,8 @@ class PushRegistratorFCMTests : FunSpec({ shouldThrow { withContext(Dispatchers.IO) { FirebaseTokenProvider( - legacyTokenTask = Tasks.forException(failure), + fidRegistrationEnabled = false, + legacyTokenTask = { Tasks.forException(failure) }, registerForFid = { Tasks.forResult(null) }, installationIdTask = { Tasks.forResult("installation-id") }, ).getToken() From 59f9b24204a94a1cdca8db1fe6c56440cc0be2d3 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 28 Jul 2026 12:11:50 -0700 Subject: [PATCH 3/3] fix: gate FID registration on project-scoped FCM credentials FCM v1 mints the installation ID against the project in FirebaseOptions but registers it under the sender ID, so the two must belong to the same Firebase project. OneSignal's fallback credentials point at a shared project, which the backend rejects with ApiException 8. Play Services below 261200000 also falls back to a legacy token that no public API exposes, so returning the FID there would upload an unroutable token. Co-authored-by: Cursor --- .../registration/impl/PushRegistratorFCM.kt | 61 ++++++++++++++++++- .../impl/PushRegistratorFCMTests.kt | 46 ++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt index ff94b7e4e9..66489647f4 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCM.kt @@ -22,6 +22,7 @@ internal class PushRegistratorFCM( companion object { private const val FCM_APP_NAME = "ONESIGNAL_SDK_FCM_APP_NAME" private const val FID_REGISTRATION_ENABLED = "firebase_messaging_installation_id_enabled" + private const val GMS_PACKAGE_NAME = "com.google.android.gms" // project_info.project_id private const val FCM_DEFAULT_PROJECT_ID = "onesignal-shared-public" @@ -36,6 +37,7 @@ internal class PushRegistratorFCM( private val projectId: String private val appId: String private val apiKey: String + private val hasBackendFcmCredentials: Boolean private var firebaseApp: FirebaseApp? = null override val providerName: String @@ -48,16 +50,18 @@ internal class PushRegistratorFCM( this.appId = fcpParams.appId ?: FCM_DEFAULT_APP_ID val defaultApiKey = String(Base64.decode(FCM_DEFAULT_API_KEY_BASE64, Base64.DEFAULT)) this.apiKey = fcpParams.apiKey ?: defaultApiKey + this.hasBackendFcmCredentials = + fcpParams.projectId != null && fcpParams.appId != null && fcpParams.apiKey != null } @Throws(ExecutionException::class, InterruptedException::class) override suspend fun getToken(senderId: String): String { initFirebaseApp(senderId) - return getTokenWithClassFirebaseMessaging() + return getTokenWithClassFirebaseMessaging(senderId) } @Throws(ExecutionException::class, InterruptedException::class) - private fun getTokenWithClassFirebaseMessaging(): String { + private fun getTokenWithClassFirebaseMessaging(senderId: String): String { // We use firebaseApp.get(FirebaseMessaging.class) instead of FirebaseMessaging.getInstance() // as the latter uses the default Firebase app. We need to use a custom Firebase app as // the senderId is provided at runtime. @@ -66,8 +70,17 @@ internal class PushRegistratorFCM( fcmInstance.javaClass.methods.firstOrNull { it.name == "register" && it.parameterTypes.isEmpty() } + val fidRegistrationEnabled = registerMethod != null && isFidRegistrationEnabled() + if (fidRegistrationEnabled) { + // FirebaseMessaging.getToken() is rejected while the manifest flag is set, so there is + // no legacy path left to fall back to when FID registration cannot be trusted. + fidRegistrationBlocker(senderId, appId, hasBackendFcmCredentials, gmsVersionCode())?.let { + throw IllegalStateException("$FID_REGISTRATION_ENABLED is enabled in the manifest, but $it.") + } + } + return FirebaseTokenProvider( - fidRegistrationEnabled = registerMethod != null && isFidRegistrationEnabled(), + fidRegistrationEnabled = fidRegistrationEnabled, legacyTokenTask = { fcmInstance.token }, // Reflection keeps firebase-messaging 23.x and 24.x binary-compatible. registerForFid = { @@ -92,6 +105,16 @@ internal class PushRegistratorFCM( } } + @Suppress("DEPRECATION") + private fun gmsVersionCode(): Int { + val context = _applicationService.appContext + return try { + context.packageManager.getPackageInfo(GMS_PACKAGE_NAME, 0).versionCode + } catch (_: PackageManager.NameNotFoundException) { + 0 + } + } + private fun initFirebaseApp(senderId: String) { if (firebaseApp != null) return val firebaseOptions = @@ -106,6 +129,38 @@ internal class PushRegistratorFCM( } } +/** + * firebase-messaging only performs FID registration on this Play Services build or newer. Below it + * `register()` quietly registers a legacy token instead, which no public API exposes. + */ +private const val MIN_GMS_VERSION_FOR_FID = 261200000 + +/** + * FID registration mints an installation ID against the Firebase project identified by [gmpAppId] + * and then registers it under [senderId], so both must describe the same project. Returns null when + * registration can be trusted, otherwise the reason it cannot. + */ +internal fun fidRegistrationBlocker( + senderId: String, + gmpAppId: String, + hasBackendFcmCredentials: Boolean, + gmsVersionCode: Int, +): String? { + // A v1 app ID is "1::android:", and the project number is the sender ID. + if (!hasBackendFcmCredentials || gmpAppId.split(':').getOrNull(1) != senderId) { + return "the FCM credentials in use do not belong to the Firebase project for sender ID " + + "$senderId. Add your Firebase service account under App Settings > Android on the " + + "OneSignal dashboard, or remove the manifest flag" + } + + if (gmsVersionCode < MIN_GMS_VERSION_FOR_FID) { + return "'Google Play services' $gmsVersionCode predates $MIN_GMS_VERSION_FOR_FID and would " + + "register a token this SDK cannot read. Update 'Google Play services', or remove the manifest flag" + } + + return null +} + internal class FirebaseTokenProvider( private val fidRegistrationEnabled: Boolean, private val legacyTokenTask: () -> Task, diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt index f3eee8ae77..0fa16c8146 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt @@ -5,6 +5,7 @@ import com.google.android.gms.tasks.Tasks import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -76,4 +77,49 @@ class PushRegistratorFCMTests : FunSpec({ thrown shouldBe failure } + + test("allows FID registration when the credentials and Play Services line up") { + fidRegistrationBlocker( + senderId = "249481192614", + gmpAppId = "1:249481192614:android:9d39e24e24034b14", + hasBackendFcmCredentials = true, + gmsVersionCode = 262634035, + ) shouldBe null + } + + test("blocks FID registration on the shared fallback credentials") { + val blocker = + fidRegistrationBlocker( + senderId = "371985261321", + gmpAppId = "1:754795614042:android:c682b8144a8dd52bc1ad63", + hasBackendFcmCredentials = false, + gmsVersionCode = 262634035, + ) + + blocker shouldContain "do not belong to the Firebase project for sender ID 371985261321" + } + + test("blocks FID registration when the app ID is from another project") { + val blocker = + fidRegistrationBlocker( + senderId = "371985261321", + gmpAppId = "1:249481192614:android:9d39e24e24034b14", + hasBackendFcmCredentials = true, + gmsVersionCode = 262634035, + ) + + blocker shouldContain "do not belong to the Firebase project" + } + + test("blocks FID registration when Play Services still registers a legacy token") { + val blocker = + fidRegistrationBlocker( + senderId = "249481192614", + gmpAppId = "1:249481192614:android:9d39e24e24034b14", + hasBackendFcmCredentials = true, + gmsVersionCode = 250834035, + ) + + blocker shouldContain "Google Play services" + } })