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..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 @@ -1,9 +1,12 @@ 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 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 @@ -18,6 +21,8 @@ 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" + private const val GMS_PACKAGE_NAME = "com.google.android.gms" // project_info.project_id private const val FCM_DEFAULT_PROJECT_ID = "onesignal-shared-public" @@ -32,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 @@ -44,26 +50,68 @@ 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. 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 + val registerMethod = + 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 = fidRegistrationEnabled, + legacyTokenTask = { fcmInstance.token }, + // Reflection keeps firebase-messaging 23.x and 24.x binary-compatible. + registerForFid = { + @Suppress("UNCHECKED_CAST") + 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 + } + } + + @Suppress("DEPRECATION") + private fun gmsVersionCode(): Int { + val context = _applicationService.appContext + return try { + context.packageManager.getPackageInfo(GMS_PACKAGE_NAME, 0).versionCode + } catch (_: PackageManager.NameNotFoundException) { + 0 } } @@ -80,3 +128,59 @@ internal class PushRegistratorFCM( firebaseApp = FirebaseApp.initializeApp(_applicationService.appContext, firebaseOptions, FCM_APP_NAME) } } + +/** + * 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, + private val registerForFid: () -> Task, + private val installationIdTask: () -> Task, +) { + fun getToken(): String { + if (fidRegistrationEnabled) { + await(registerForFid()) + return await(installationIdTask()) + } + + return await(legacyTokenTask()) + } + + private fun await(task: Task): T { + try { + return Tasks.await(task) + } catch (e: ExecutionException) { + throw task.exception ?: e + } + } +} 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..0fa16c8146 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt @@ -0,0 +1,125 @@ +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.kotest.matchers.string.shouldContain +import io.mockk.every +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 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( + 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 without calling the disabled legacy API") { + var registrationCompleted = false + val legacyTokenTask = mockk<() -> com.google.android.gms.tasks.Task>() + + val token = + withContext(Dispatchers.IO) { + FirebaseTokenProvider( + fidRegistrationEnabled = true, + legacyTokenTask = legacyTokenTask, + registerForFid = { + registrationCompleted = true + Tasks.forResult(null) + }, + installationIdTask = { + registrationCompleted shouldBe true + Tasks.forResult("installation-id") + }, + ).getToken() + } + + token shouldBe "installation-id" + verify(exactly = 0) { legacyTokenTask() } + } + + test("does not mask unrelated legacy token failures") { + val failure = IllegalStateException("Firebase app was deleted") + + val thrown = + shouldThrow { + withContext(Dispatchers.IO) { + FirebaseTokenProvider( + fidRegistrationEnabled = false, + legacyTokenTask = { Tasks.forException(failure) }, + registerForFid = { Tasks.forResult(null) }, + installationIdTask = { Tasks.forResult("installation-id") }, + ).getToken() + } + } + + 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" + } +})