From 851448445af5b6fe2eea6a869045321205c3d566 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 18:36:13 +0000 Subject: [PATCH] fix: [SDK-4946] prefer google-services.json over the shared FCM project Read the host app's google-services.json (via the default FirebaseApp) so FCM registration uses one consistent customer Firebase project instead of mixing the dashboard sender id with onesignal-shared-public (754795614042). When the legacy token API is disabled, fall back to Firebase Installation ID registration against that same host project. Co-authored-by: abdulraqeeb33 --- OneSignalSDK/coverage/jacoco.gradle | 10 + .../onesignal/notifications/build.gradle | 2 + .../notifications/consumer-rules.pro | 10 + .../registration/impl/FCMTokenProvider.kt | 114 ++++++ .../impl/FcmFirebaseConfigResolver.kt | 87 ++++ .../registration/impl/PushRegistratorFCM.kt | 196 +++++++-- .../impl/FCMTokenProviderTests.kt | 158 ++++++++ .../impl/FcmFirebaseConfigResolverTests.kt | 126 ++++++ .../impl/PushRegistratorFCMTests.kt | 372 ++++++++++++++++++ examples/build.md | 2 +- 10 files changed, 1045 insertions(+), 32 deletions(-) create mode 100644 OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/FCMTokenProvider.kt create mode 100644 OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolver.kt create mode 100644 OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FCMTokenProviderTests.kt create mode 100644 OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolverTests.kt create mode 100644 OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt diff --git a/OneSignalSDK/coverage/jacoco.gradle b/OneSignalSDK/coverage/jacoco.gradle index c7b551de28..45ee9f09f2 100644 --- a/OneSignalSDK/coverage/jacoco.gradle +++ b/OneSignalSDK/coverage/jacoco.gradle @@ -16,6 +16,16 @@ subprojects { testCoverageEnabled = true } } + + testOptions { + // Robolectric loads classes through its own classloader, which leaves them without + // a code source location. JaCoCo skips those by default, so Robolectric tests + // would contribute no coverage at all. + unitTests.all { test -> + test.jacoco.includeNoLocationClasses = true + test.jacoco.excludes = ['jdk.internal.*'] + } + } } def coverageExcludes = [ diff --git a/OneSignalSDK/onesignal/notifications/build.gradle b/OneSignalSDK/onesignal/notifications/build.gradle index be636bb3bf..411010cce4 100644 --- a/OneSignalSDK/onesignal/notifications/build.gradle +++ b/OneSignalSDK/onesignal/notifications/build.gradle @@ -76,6 +76,8 @@ dependencies { // NOTE: firebase-messaging:24.0.0 requires customer's project to use // compileSdkVersion 34 or higher. + // `require` is intentionally non-strict: this module compiles against the preferred 24.0.0, + // while an app can select a newer version through Gradle conflict resolution. api('com.google.firebase:firebase-messaging') { version { require '[23.0.8, 24.0.99]' diff --git a/OneSignalSDK/onesignal/notifications/consumer-rules.pro b/OneSignalSDK/onesignal/notifications/consumer-rules.pro index b7a21ca10b..f8942fd5c9 100644 --- a/OneSignalSDK/onesignal/notifications/consumer-rules.pro +++ b/OneSignalSDK/onesignal/notifications/consumer-rules.pro @@ -25,6 +25,16 @@ -dontwarn com.google.firebase.** -dontwarn com.google.android.gms.** +# PushRegistratorFCM looks up FirebaseMessaging.register() by name, because this module compiles +# against firebase-messaging 24.x where the method does not exist yet. Nothing references it +# symbolically, it carries no @Keep, and firebase-messaging ships no consumer rules, so R8 full mode +# (AGP 8+) renames it along with the rest of the class, causing: +# java.lang.NoSuchMethodException: com.google.firebase.messaging.FirebaseMessaging.register [] +# keepclassmembers rather than keep, so Huawei apps that exclude firebase-messaging are unaffected. +-keepclassmembers class com.google.firebase.messaging.FirebaseMessaging { + public *** 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/FCMTokenProvider.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/FCMTokenProvider.kt new file mode 100644 index 0000000000..623420c54f --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/FCMTokenProvider.kt @@ -0,0 +1,114 @@ +package com.onesignal.notifications.internal.registration.impl + +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import java.lang.reflect.InvocationTargetException +import java.util.concurrent.ExecutionException + +/** + * Retrieves an FCM registration token, falling back to Firebase Installation ID registration + * when the host app has opted into it. + * + * Opting in (via `firebase_messaging_installation_id_enabled`) disables the legacy token API + * for the whole process, not just the [com.google.firebase.FirebaseApp] that opted in. In that + * case the only usable token is a Firebase Installation ID issued by a real Firebase project — + * which means the host app's `google-services.json`, not OneSignal's shared project. + */ +internal object FCMTokenProvider { + /** + * The Firebase Installation ID registration that replaces the legacy token API, along with the + * sender id of the Firebase project it would register against. + */ + class InstallationIdRegistration( + val senderId: String?, + val register: () -> Task<*>, + val installationId: () -> Task, + ) + + fun getToken( + senderId: String, + installationIdEnabled: () -> String, + legacyToken: () -> Task, + installationIdRegistration: () -> InstallationIdRegistration?, + ): String { + return try { + await(legacyToken()) + } catch (e: IllegalStateException) { + if (!isLegacyTokenApiDisabled(e)) throw e + + registerInstallationId(senderId, installationIdEnabled(), installationIdRegistration()) + } + } + + private fun registerInstallationId( + senderId: String, + installationIdEnabled: String, + registration: InstallationIdRegistration?, + ): String { + val optedIn = "firebase_messaging_installation_id_enabled=$installationIdEnabled" + + if (registration == null) { + throw IllegalStateException( + "Firebase Installation ID registration is enabled ($optedIn) but this app has no " + + "default FirebaseApp to register with. Add your Firebase configuration " + + "(google-services.json) and apply the google-services Gradle plugin, or set " + + "firebase_messaging_installation_id_enabled to false in your manifest to keep " + + "using the legacy FCM token API.", + ) + } + + if (registration.senderId != senderId) { + throw IllegalStateException( + "Firebase Installation ID registration is enabled ($optedIn) but the default " + + "FirebaseApp uses sender id ${registration.senderId}, while OneSignal is " + + "configured with sender id $senderId. Point google-services.json and the " + + "OneSignal dashboard at the same Firebase project, or set " + + "firebase_messaging_installation_id_enabled to false in your manifest to keep " + + "using the legacy FCM token API.", + ) + } + + await(registration.register()) + return await(registration.installationId()) + } + + /** + * Calls `register()` reflectively. [com.google.firebase.messaging.FirebaseMessaging.register] + * was added in firebase-messaging 25.1.0. This module compiles against the preferred 24.0.0, + * but the non-strict Gradle constraint lets apps select newer versions through conflict + * resolution. + */ + fun invokeRegister(target: Any): Task<*> { + val register = + try { + target.javaClass.getMethod("register") + } catch (e: NoSuchMethodException) { + throw IllegalStateException( + "Firebase Installation ID registration is enabled but " + + "FirebaseMessaging.register() was not found. It requires firebase-messaging " + + "25.1.0 or newer, and has to survive minification, so check that OneSignal's " + + "consumer ProGuard rules are applied.", + e, + ) + } + + return try { + register.invoke(target) as Task<*> + } catch (e: InvocationTargetException) { + throw e.targetException ?: e + } + } + + private fun isLegacyTokenApiDisabled(exception: IllegalStateException): Boolean { + val message = exception.message ?: return false + return message.contains("API disabled") && message.contains("register()") + } + + 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/main/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolver.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolver.kt new file mode 100644 index 0000000000..7adaa71766 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolver.kt @@ -0,0 +1,87 @@ +package com.onesignal.notifications.internal.registration.impl + +/** + * Client-side Firebase project credentials used to register for FCM. + * + * Google requires the sender id, project id, application id, and api key to all belong to the + * same Firebase project. Mixing a customer's sender id with OneSignal's shared project is what + * the legacy path did, and Play Services rejects that for Firebase Installation ID registration. + */ +internal data class FcmProjectCredentials( + val senderId: String, + val projectId: String, + val applicationId: String, + val apiKey: String, +) { + val isComplete: Boolean + get() = + senderId.isNotBlank() && + projectId.isNotBlank() && + applicationId.isNotBlank() && + apiKey.isNotBlank() +} + +internal data class FcmFirebaseConfig( + val credentials: FcmProjectCredentials, + val source: Source, + val reuseDefaultApp: Boolean, +) { + enum class Source { + /** + * Host app's default [com.google.firebase.FirebaseApp], initialized from + * `google-services.json` (the google-services Gradle plugin compiles that file into + * string resources; [com.google.firebase.FirebaseApp.initializeApp] reads them). + */ + GOOGLE_SERVICES, + + /** Complete `fcm` object from `android_params.js`. */ + BACKEND, + + /** + * OneSignal's shared public Firebase project. Kept as a last-resort fallback for apps + * that never added `google-services.json`. Does not work with Installation ID registration. + */ + SHARED_DEFAULT, + } +} + +/** + * Picks a single consistent Firebase project for FCM registration. + * + * Preference order: + * 1. The host app's default Firebase app, when its sender id matches the OneSignal dashboard. + * 2. Backend-provided FCM params (project id / app id / api key) with the dashboard sender id. + * 3. OneSignal's shared public project, still pairing the dashboard sender id (legacy). + */ +internal object FcmFirebaseConfigResolver { + fun resolve( + dashboardSenderId: String, + defaultApp: FcmProjectCredentials?, + backend: FcmProjectCredentials?, + sharedDefault: FcmProjectCredentials, + ): FcmFirebaseConfig { + val matchingDefaultApp = + defaultApp?.takeIf { it.isComplete && it.senderId == dashboardSenderId } + val completeBackend = backend?.takeIf { it.isComplete } + return when { + matchingDefaultApp != null -> + FcmFirebaseConfig( + credentials = matchingDefaultApp, + source = FcmFirebaseConfig.Source.GOOGLE_SERVICES, + reuseDefaultApp = true, + ) + completeBackend != null -> + FcmFirebaseConfig( + credentials = completeBackend, + source = FcmFirebaseConfig.Source.BACKEND, + reuseDefaultApp = false, + ) + else -> + FcmFirebaseConfig( + credentials = sharedDefault, + source = FcmFirebaseConfig.Source.SHARED_DEFAULT, + reuseDefaultApp = false, + ) + } + } +} 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..37c65423a3 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,13 +1,16 @@ package com.onesignal.notifications.internal.registration.impl +import android.content.Context import android.util.Base64 -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.common.AndroidUtils import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.device.IDeviceService +import com.onesignal.debug.internal.logging.Logging import java.util.concurrent.ExecutionException internal class PushRegistratorFCM( @@ -15,11 +18,14 @@ internal class PushRegistratorFCM( val _applicationService: IApplicationService, upgradePrompt: GooglePlayServicesUpgradePrompt, deviceService: IDeviceService, + private val defaultFirebaseApp: (Context) -> FirebaseApp? = { hostDefaultFirebaseApp(it) }, ) : PushRegistratorAbstractGoogle(deviceService, _configModelStore, upgradePrompt) { companion object { private const val FCM_APP_NAME = "ONESIGNAL_SDK_FCM_APP_NAME" - // project_info.project_id + private const val INSTALLATION_ID_ENABLED_METADATA = "firebase_messaging_installation_id_enabled" + + // project_info.project_id from OneSignal's shared public google-services.json private const val FCM_DEFAULT_PROJECT_ID = "onesignal-shared-public" // client.client_info.mobilesdk_app_id @@ -27,56 +33,184 @@ internal class PushRegistratorFCM( // client.api_key.current_key private const val FCM_DEFAULT_API_KEY_BASE64 = "QUl6YVN5QW5UTG41LV80TWMyYTJQLWRLVWVFLWFCdGd5Q3JqbFlV" - } - private val projectId: String - private val appId: String - private val apiKey: String + /** + * The host app's default Firebase app, created from `google-services.json` when present. + * + * [FirebaseApp.initializeApp] with only a [Context] reads the string resources the + * google-services Gradle plugin generated (`google_app_id`, `gcm_defaultSenderId`, + * `google_api_key`, `project_id`). If the default app is already initialized it is + * returned; if those resources are missing it returns null. + */ + internal fun hostDefaultFirebaseApp(context: Context): FirebaseApp? { + return try { + FirebaseApp.initializeApp(context) + } catch (e: IllegalStateException) { + Logging.debug("Unable to initialize the default FirebaseApp from google-services.json", e) + FirebaseApp.getApps(context).firstOrNull { it.name == FirebaseApp.DEFAULT_APP_NAME } + } + } - private var firebaseApp: FirebaseApp? = null - override val providerName: String - get() = "FCM" + internal fun credentialsFrom(options: FirebaseOptions?): FcmProjectCredentials? { + return options?.let(::fromOptions) + } - init { - val fcpParams = _configModelStore.model.fcmParams + private fun fromOptions(options: FirebaseOptions): FcmProjectCredentials? { + val credentials = + FcmProjectCredentials( + senderId = options.gcmSenderId.orEmpty(), + projectId = options.projectId.orEmpty(), + applicationId = options.applicationId, + apiKey = options.apiKey, + ) + return credentials.takeIf { it.isComplete } + } - this.projectId = fcpParams.projectId ?: FCM_DEFAULT_PROJECT_ID - 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 + internal fun sharedDefaultCredentials(senderId: String): FcmProjectCredentials { + val defaultApiKey = String(Base64.decode(FCM_DEFAULT_API_KEY_BASE64, Base64.DEFAULT)) + return FcmProjectCredentials( + senderId = senderId, + projectId = FCM_DEFAULT_PROJECT_ID, + applicationId = FCM_DEFAULT_APP_ID, + apiKey = defaultApiKey, + ) + } } + private var firebaseApp: FirebaseApp? = null + private var resolvedSource: FcmFirebaseConfig.Source? = null + + override val providerName: String + get() = "FCM" + @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 { - // 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. + private fun getTokenWithClassFirebaseMessaging(senderId: String): String { 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 FCMTokenProvider.getToken( + senderId, + ::installationIdEnabled, + { fcmInstance.token }, + ::installationIdRegistration, + ) + } + + // Manifest merging means the flag can arrive from a dependency instead of the app's own + // manifest, so report what the app actually resolved to. Read as a raw value because a + // string "true" reads as false when asked for a boolean. + private fun installationIdEnabled(): String { + val metaData = AndroidUtils.getManifestMetaBundle(_applicationService.appContext) + return metaData?.get(INSTALLATION_ID_ENABLED_METADATA)?.toString() ?: "not set" + } + + private fun installationIdRegistration(): FCMTokenProvider.InstallationIdRegistration? { + val hostApp = firebaseAppForInstallationId() ?: return null + return FCMTokenProvider.InstallationIdRegistration( + senderId = hostApp.options.gcmSenderId, + register = { FCMTokenProvider.invokeRegister(hostApp.get(FirebaseMessaging::class.java)) }, + installationId = { FirebaseInstallations.getInstance(hostApp).id }, + ) + } + + private fun firebaseAppForInstallationId(): FirebaseApp? { + // Installation IDs are issued per Firebase project. The shared OneSignal project cannot + // mint a usable one for a customer app, so only reuse the FirebaseApp we resolved when it + // came from google-services.json or complete backend params. + val current = firebaseApp + if (current != null && resolvedSource != FcmFirebaseConfig.Source.SHARED_DEFAULT) { + return current } + return defaultFirebaseApp(_applicationService.appContext) } private fun initFirebaseApp(senderId: String) { if (firebaseApp != null) return + + val context = _applicationService.appContext + val hostApp = defaultFirebaseApp(context) + val hostCredentials = credentialsFrom(hostApp?.options) + logIfHostSenderMismatches(senderId, hostCredentials) + + val resolved = + FcmFirebaseConfigResolver.resolve( + dashboardSenderId = senderId, + defaultApp = hostCredentials, + backend = backendCredentials(senderId), + sharedDefault = sharedDefaultCredentials(senderId), + ) + resolvedSource = resolved.source + logResolvedConfig(resolved) + + if (resolved.reuseDefaultApp && hostApp != null) { + firebaseApp = hostApp + return + } + val firebaseOptions = FirebaseOptions .Builder() - .setGcmSenderId(senderId) - .setApplicationId(appId) - .setApiKey(apiKey) - .setProjectId(projectId) + .setGcmSenderId(resolved.credentials.senderId) + .setApplicationId(resolved.credentials.applicationId) + .setApiKey(resolved.credentials.apiKey) + .setProjectId(resolved.credentials.projectId) .build() - firebaseApp = FirebaseApp.initializeApp(_applicationService.appContext, firebaseOptions, FCM_APP_NAME) + firebaseApp = FirebaseApp.initializeApp(context, firebaseOptions, FCM_APP_NAME) + } + + private fun backendCredentials(senderId: String): FcmProjectCredentials? { + val fcmParams = _configModelStore.model.fcmParams + val projectId = fcmParams.projectId + val appId = fcmParams.appId + val apiKey = fcmParams.apiKey + if (projectId.isNullOrBlank() || appId.isNullOrBlank() || apiKey.isNullOrBlank()) { + return null + } + return FcmProjectCredentials( + senderId = senderId, + projectId = projectId, + applicationId = appId, + apiKey = apiKey, + ) + } + + private fun logIfHostSenderMismatches( + dashboardSenderId: String, + hostCredentials: FcmProjectCredentials?, + ) { + if (hostCredentials == null || hostCredentials.senderId == dashboardSenderId) return + Logging.warn( + "google-services.json sender id ${hostCredentials.senderId} does not match the " + + "OneSignal dashboard sender id $dashboardSenderId. FCM will not use the host " + + "Firebase project until they match. Point both at the same Firebase project.", + ) + } + + private fun logResolvedConfig(resolved: FcmFirebaseConfig) { + val credentials = resolved.credentials + when (resolved.source) { + FcmFirebaseConfig.Source.GOOGLE_SERVICES -> + Logging.info( + "FCM registration is using this app's google-services.json " + + "(project=${credentials.projectId}, sender=${credentials.senderId}).", + ) + FcmFirebaseConfig.Source.BACKEND -> + Logging.info( + "FCM registration is using Firebase credentials from OneSignal " + + "(project=${credentials.projectId}, sender=${credentials.senderId}).", + ) + FcmFirebaseConfig.Source.SHARED_DEFAULT -> + Logging.info( + "FCM registration is using OneSignal's shared Firebase project " + + "(${credentials.projectId} / ${credentials.senderId}). Add your app's " + + "google-services.json and apply the google-services Gradle plugin so " + + "registration uses your own Firebase project. Google now requires this " + + "for Firebase Installation ID registration.", + ) + } } } diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FCMTokenProviderTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FCMTokenProviderTests.kt new file mode 100644 index 0000000000..4630f3e418 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FCMTokenProviderTests.kt @@ -0,0 +1,158 @@ +package com.onesignal.notifications.internal.registration.impl + +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskCompletionSource +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.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val SENDER_ID = "388536902528" + +private fun completedTask(): Task = TaskCompletionSource().apply { setResult(null) }.task + +private fun failedTask(exception: Exception): Task = + TaskCompletionSource().apply { setException(exception) }.task + +private fun registration( + senderId: String? = SENDER_ID, + register: () -> Task<*> = { completedTask() }, + installationId: () -> Task = { Tasks.forResult("installation-id") }, +) = FCMTokenProvider.InstallationIdRegistration(senderId, register, installationId) + +private class Registrar(private val exception: Exception? = null) { + fun register(): Task { + exception?.let { throw it } + return completedTask() + } +} + +private class WithoutRegister + +@RobolectricTest +class FCMTokenProviderTests : FunSpec({ + val disabledLegacyApi = IllegalStateException("API disabled. Please use {@link #register()} instead.") + + test("returns the legacy FCM token when the API is enabled") { + val token = + withContext(Dispatchers.IO) { + FCMTokenProvider.getToken(SENDER_ID, { "true" }, { Tasks.forResult("fcm-token") }) { + throw AssertionError("should not fall back to installation id registration") + } + } + + token shouldBe "fcm-token" + } + + test("registers the installation id when the legacy API is disabled") { + var registered = false + val token = + withContext(Dispatchers.IO) { + FCMTokenProvider.getToken(SENDER_ID, { "true" }, { Tasks.forException(disabledLegacyApi) }) { + registration(register = { + registered = true + completedTask() + }) + } + } + + token shouldBe "installation-id" + registered shouldBe true + } + + test("does not register for unrelated IllegalStateExceptions") { + val unrelated = IllegalStateException("Firebase is not initialized") + + val thrown = + withContext(Dispatchers.IO) { + shouldThrow { + FCMTokenProvider.getToken(SENDER_ID, { "true" }, { Tasks.forException(unrelated) }) { + throw AssertionError("should not fall back to installation id registration") + } + } + } + + thrown shouldBe unrelated + } + + test("explains the problem when there is no default FirebaseApp to register with") { + val thrown = + withContext(Dispatchers.IO) { + shouldThrow { + FCMTokenProvider.getToken(SENDER_ID, { "true" }, { Tasks.forException(disabledLegacyApi) }) { null } + } + } + + thrown.message!! shouldContain "no default FirebaseApp" + thrown.message!! shouldContain "google-services.json" + } + + test("reports the manifest flag value it resolved, including when the app never set it") { + val thrown = + withContext(Dispatchers.IO) { + shouldThrow { + FCMTokenProvider.getToken(SENDER_ID, { "not set" }, { Tasks.forException(disabledLegacyApi) }) { null } + } + } + + thrown.message!! shouldContain "firebase_messaging_installation_id_enabled=not set" + } + + test("explains the problem when the default FirebaseApp uses a different sender id") { + val thrown = + withContext(Dispatchers.IO) { + shouldThrow { + FCMTokenProvider.getToken(SENDER_ID, { "true" }, { Tasks.forException(disabledLegacyApi) }) { + registration( + senderId = "999999999999", + register = { throw AssertionError("should not register on a sender id mismatch") }, + ) + } + } + } + + thrown.message!! shouldContain "sender id 999999999999" + } + + test("propagates registration failures") { + val registrationFailure = IllegalStateException("Registration failed") + + val thrown = + withContext(Dispatchers.IO) { + shouldThrow { + FCMTokenProvider.getToken(SENDER_ID, { "true" }, { Tasks.forException(disabledLegacyApi) }) { + registration( + register = { failedTask(registrationFailure) }, + installationId = { throw AssertionError("should not run after registration fails") }, + ) + } + } + } + + thrown shouldBe registrationFailure + } + + test("invokes register reflectively") { + FCMTokenProvider.invokeRegister(Registrar()).isSuccessful shouldBe true + } + + test("unwraps what register throws instead of surfacing the reflection wrapper") { + val cause = IllegalStateException("register blew up") + + val thrown = shouldThrow { FCMTokenProvider.invokeRegister(Registrar(cause)) } + + thrown shouldBe cause + } + + test("explains the problem when register is missing") { + val thrown = shouldThrow { FCMTokenProvider.invokeRegister(WithoutRegister()) } + + thrown.message!! shouldContain "25.1.0 or newer" + thrown.cause.shouldBeInstanceOf() + } +}) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolverTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolverTests.kt new file mode 100644 index 0000000000..6963f5e1e4 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/FcmFirebaseConfigResolverTests.kt @@ -0,0 +1,126 @@ +package com.onesignal.notifications.internal.registration.impl + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +private const val DASHBOARD_SENDER_ID = "388536902528" +private const val OTHER_SENDER_ID = "754795614042" + +private fun credentials( + senderId: String = DASHBOARD_SENDER_ID, + projectId: String = "customer-project", + applicationId: String = "1:$senderId:android:abc", + apiKey: String = "AIza-customer", +) = FcmProjectCredentials(senderId, projectId, applicationId, apiKey) + +private val sharedDefault = + credentials( + senderId = DASHBOARD_SENDER_ID, + projectId = "onesignal-shared-public", + applicationId = "1:754795614042:android:c682b8144a8dd52bc1ad63", + apiKey = "AIza-shared", + ) + +class FcmFirebaseConfigResolverTests : FunSpec({ + test("prefers the host google-services.json FirebaseApp when its sender id matches the dashboard") { + val host = credentials() + + val resolved = + FcmFirebaseConfigResolver.resolve( + dashboardSenderId = DASHBOARD_SENDER_ID, + defaultApp = host, + backend = credentials(projectId = "backend-project"), + sharedDefault = sharedDefault, + ) + + resolved.source shouldBe FcmFirebaseConfig.Source.GOOGLE_SERVICES + resolved.reuseDefaultApp shouldBe true + resolved.credentials shouldBe host + } + + test("does not use the host FirebaseApp when its sender id does not match the dashboard") { + val host = credentials(senderId = OTHER_SENDER_ID) + val backend = credentials(projectId = "backend-project") + + val resolved = + FcmFirebaseConfigResolver.resolve( + dashboardSenderId = DASHBOARD_SENDER_ID, + defaultApp = host, + backend = backend, + sharedDefault = sharedDefault, + ) + + resolved.source shouldBe FcmFirebaseConfig.Source.BACKEND + resolved.reuseDefaultApp shouldBe false + resolved.credentials shouldBe backend + } + + test("does not use incomplete host Firebase options") { + val host = credentials(projectId = " ") + val backend = credentials(projectId = "backend-project") + + val resolved = + FcmFirebaseConfigResolver.resolve( + dashboardSenderId = DASHBOARD_SENDER_ID, + defaultApp = host, + backend = backend, + sharedDefault = sharedDefault, + ) + + resolved.source shouldBe FcmFirebaseConfig.Source.BACKEND + resolved.credentials shouldBe backend + } + + test("uses complete backend FCM params when google-services.json is absent") { + val backend = credentials(projectId = "backend-project") + + val resolved = + FcmFirebaseConfigResolver.resolve( + dashboardSenderId = DASHBOARD_SENDER_ID, + defaultApp = null, + backend = backend, + sharedDefault = sharedDefault, + ) + + resolved.source shouldBe FcmFirebaseConfig.Source.BACKEND + resolved.reuseDefaultApp shouldBe false + resolved.credentials shouldBe backend + } + + test("does not use incomplete backend FCM params") { + val backend = credentials(apiKey = "") + + val resolved = + FcmFirebaseConfigResolver.resolve( + dashboardSenderId = DASHBOARD_SENDER_ID, + defaultApp = null, + backend = backend, + sharedDefault = sharedDefault, + ) + + resolved.source shouldBe FcmFirebaseConfig.Source.SHARED_DEFAULT + resolved.credentials shouldBe sharedDefault + } + + test("falls back to OneSignal's shared Firebase project when nothing else is available") { + val resolved = + FcmFirebaseConfigResolver.resolve( + dashboardSenderId = DASHBOARD_SENDER_ID, + defaultApp = null, + backend = null, + sharedDefault = sharedDefault, + ) + + resolved.source shouldBe FcmFirebaseConfig.Source.SHARED_DEFAULT + resolved.reuseDefaultApp shouldBe false + resolved.credentials shouldBe sharedDefault + } + + test("treats blank credential fields as incomplete") { + credentials(senderId = " ").isComplete shouldBe false + credentials(projectId = "").isComplete shouldBe false + credentials(applicationId = " ").isComplete shouldBe false + credentials(apiKey = "").isComplete shouldBe false + credentials().isComplete shouldBe true + } +}) 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..a81aa93478 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/registration/impl/PushRegistratorFCMTests.kt @@ -0,0 +1,372 @@ +package com.onesignal.notifications.internal.registration.impl + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +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.messaging.FirebaseMessaging +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.MockHelper +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.CapturingSlot +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val SENDER_ID = "388536902528" +private const val HOST_PROJECT_ID = "customer-project" +private const val HOST_APP_ID = "1:388536902528:android:abc" +private const val HOST_API_KEY = "AIza-customer" + +private fun firebaseOptions( + senderId: String = SENDER_ID, + projectId: String = HOST_PROJECT_ID, + applicationId: String = HOST_APP_ID, + apiKey: String = HOST_API_KEY, +): FirebaseOptions { + return FirebaseOptions + .Builder() + .setGcmSenderId(senderId) + .setProjectId(projectId) + .setApplicationId(applicationId) + .setApiKey(apiKey) + .build() +} + +private fun defaultApp( + senderId: String = SENDER_ID, + messaging: FirebaseMessaging? = null, +): FirebaseApp { + val app = mockk() + every { app.name } returns FirebaseApp.DEFAULT_APP_NAME + every { app.options } returns firebaseOptions(senderId = senderId) + if (messaging != null) { + every { app.get(FirebaseMessaging::class.java) } returns messaging + } + return app +} + +private fun registrator( + legacyToken: Task, + hostApp: FirebaseApp? = null, + captureNamedOptions: CapturingSlot? = null, +): PushRegistratorFCM { + val messaging = mockk() + every { messaging.token } returns legacyToken + + val namedApp = mockk() + every { namedApp.get(FirebaseMessaging::class.java) } returns messaging + every { namedApp.options } returns firebaseOptions() + + mockkStatic(FirebaseApp::class) + if (captureNamedOptions != null) { + every { + FirebaseApp.initializeApp(any(), capture(captureNamedOptions), any()) + } returns namedApp + } else { + every { + FirebaseApp.initializeApp(any(), any(), any()) + } returns namedApp + } + + val applicationService = mockk() + every { applicationService.appContext } returns ApplicationProvider.getApplicationContext() + + return PushRegistratorFCM( + MockHelper.configModelStore(), + applicationService, + mockk(relaxed = true), + mockk(relaxed = true), + defaultFirebaseApp = { hostApp }, + ) +} + +@RobolectricTest +class PushRegistratorFCMTests : FunSpec({ + val disabledLegacyApi = IllegalStateException("API disabled. Please use {@link #register()} instead.") + + beforeEach { + Logging.logLevel = LogLevel.NONE + } + + afterEach { unmockkAll() } + + test("reuses the host FirebaseApp when google-services.json matches the dashboard sender id") { + val hostMessaging = mockk() + every { hostMessaging.token } returns Tasks.forResult("host-token") + val hostApp = defaultApp(messaging = hostMessaging) + val registrator = registrator(legacyToken = Tasks.forResult("unused"), hostApp = hostApp) + + val token = withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + + token shouldBe "host-token" + } + + test("initializes a named FirebaseApp from backend FCM params when google-services.json is absent") { + val optionsSlot = slot() + val configStore = + MockHelper.configModelStore { + it.fcmParams.projectId = "backend-project" + it.fcmParams.appId = "1:388536902528:android:backend" + it.fcmParams.apiKey = "AIza-backend" + } + val messaging = mockk() + every { messaging.token } returns Tasks.forResult("backend-token") + val namedApp = mockk() + every { namedApp.get(FirebaseMessaging::class.java) } returns messaging + + mockkStatic(FirebaseApp::class) + every { + FirebaseApp.initializeApp(any(), capture(optionsSlot), any()) + } returns namedApp + + val applicationService = mockk() + every { applicationService.appContext } returns ApplicationProvider.getApplicationContext() + + val registrator = + PushRegistratorFCM( + configStore, + applicationService, + mockk(relaxed = true), + mockk(relaxed = true), + defaultFirebaseApp = { null }, + ) + + val token = withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + + token shouldBe "backend-token" + optionsSlot.captured.projectId shouldBe "backend-project" + optionsSlot.captured.gcmSenderId shouldBe SENDER_ID + optionsSlot.captured.applicationId shouldBe "1:388536902528:android:backend" + optionsSlot.captured.apiKey shouldBe "AIza-backend" + } + + test("falls back to OneSignal's shared Firebase project when no host or backend credentials exist") { + val optionsSlot = slot() + val registrator = + registrator( + legacyToken = Tasks.forResult("shared-token"), + hostApp = null, + captureNamedOptions = optionsSlot, + ) + + val token = withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + + token shouldBe "shared-token" + optionsSlot.captured.projectId shouldBe "onesignal-shared-public" + optionsSlot.captured.gcmSenderId shouldBe SENDER_ID + optionsSlot.captured.applicationId shouldBe "1:754795614042:android:c682b8144a8dd52bc1ad63" + } + + test("does not reuse a host FirebaseApp whose sender id differs from the dashboard") { + val optionsSlot = slot() + val hostApp = defaultApp(senderId = "999999999999") + val registrator = + registrator( + legacyToken = Tasks.forResult("named-token"), + hostApp = hostApp, + captureNamedOptions = optionsSlot, + ) + + val token = withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + + token shouldBe "named-token" + optionsSlot.captured.projectId shouldBe "onesignal-shared-public" + } + + test("explains the problem when installation id registration is enabled without a host FirebaseApp") { + val registrator = registrator(legacyToken = Tasks.forException(disabledLegacyApi), hostApp = null) + + val thrown = + withContext(Dispatchers.IO) { + shouldThrow { registrator.getToken(SENDER_ID) } + } + + thrown.message!! shouldContain "no default FirebaseApp" + thrown.message!! shouldContain "firebase_messaging_installation_id_enabled=not set" + } + + test("attempts installation id registration against the host FirebaseApp when the legacy API is disabled") { + val hostMessaging = mockk() + every { hostMessaging.token } returns Tasks.forException(disabledLegacyApi) + val hostApp = defaultApp(messaging = hostMessaging) + val registrator = registrator(legacyToken = Tasks.forException(disabledLegacyApi), hostApp = hostApp) + + val thrown = + withContext(Dispatchers.IO) { + shouldThrow { registrator.getToken(SENDER_ID) } + } + + thrown.message!! shouldContain "25.1.0 or newer" + } + + test("initializes the FirebaseApp only once") { + val hostMessaging = mockk() + every { hostMessaging.token } returns Tasks.forResult("host-token") + val hostApp = defaultApp(messaging = hostMessaging) + val registrator = registrator(legacyToken = Tasks.forResult("unused"), hostApp = hostApp) + + withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + val token = withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + + token shouldBe "host-token" + } + + test("maps FirebaseOptions into FCM project credentials") { + val credentials = PushRegistratorFCM.credentialsFrom(firebaseOptions()) + + credentials!!.senderId shouldBe SENDER_ID + credentials.projectId shouldBe HOST_PROJECT_ID + credentials.applicationId shouldBe HOST_APP_ID + credentials.apiKey shouldBe HOST_API_KEY + } + + test("returns null credentials when FirebaseOptions are missing a sender id") { + val options = + FirebaseOptions + .Builder() + .setProjectId(HOST_PROJECT_ID) + .setApplicationId(HOST_APP_ID) + .setApiKey(HOST_API_KEY) + .build() + + PushRegistratorFCM.credentialsFrom(options) shouldBe null + PushRegistratorFCM.credentialsFrom(null) shouldBe null + } + + test("returns null credentials when FirebaseOptions are missing a project id") { + val options = + FirebaseOptions + .Builder() + .setGcmSenderId(SENDER_ID) + .setApplicationId(HOST_APP_ID) + .setApiKey(HOST_API_KEY) + .build() + + PushRegistratorFCM.credentialsFrom(options) shouldBe null + } + + test("reads backend FCM params at registration time rather than construction") { + val optionsSlot = slot() + val configStore = MockHelper.configModelStore() + val messaging = mockk() + every { messaging.token } returns Tasks.forResult("late-backend-token") + val namedApp = mockk() + every { namedApp.get(FirebaseMessaging::class.java) } returns messaging + + mockkStatic(FirebaseApp::class) + every { + FirebaseApp.initializeApp(any(), capture(optionsSlot), any()) + } returns namedApp + + val applicationService = mockk() + every { applicationService.appContext } returns ApplicationProvider.getApplicationContext() + + val registrator = + PushRegistratorFCM( + configStore, + applicationService, + mockk(relaxed = true), + mockk(relaxed = true), + defaultFirebaseApp = { null }, + ) + + configStore.model.fcmParams.projectId = "late-backend-project" + configStore.model.fcmParams.appId = "1:388536902528:android:late" + configStore.model.fcmParams.apiKey = "AIza-late" + + val token = withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + + token shouldBe "late-backend-token" + optionsSlot.captured.projectId shouldBe "late-backend-project" + } + + test("hostDefaultFirebaseApp reads google-services.json via FirebaseApp.initializeApp") { + mockkStatic(FirebaseApp::class) + val app = mockk() + every { FirebaseApp.initializeApp(any()) } returns app + + val result = + PushRegistratorFCM.hostDefaultFirebaseApp( + ApplicationProvider.getApplicationContext(), + ) + + result shouldBe app + } + + test("hostDefaultFirebaseApp falls back to getApps when initializeApp throws") { + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(any()) } throws IllegalStateException("failed") + val app = mockk() + every { app.name } returns FirebaseApp.DEFAULT_APP_NAME + every { FirebaseApp.getApps(any()) } returns listOf(app) + + val result = + PushRegistratorFCM.hostDefaultFirebaseApp( + ApplicationProvider.getApplicationContext(), + ) + + result shouldBe app + } + + test("hostDefaultFirebaseApp returns null when initializeApp throws and no default app exists") { + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(any()) } throws IllegalStateException("failed") + val namedApp = mockk() + every { namedApp.name } returns "ONESIGNAL_SDK_FCM_APP_NAME" + every { FirebaseApp.getApps(any()) } returns listOf(namedApp) + + val result = + PushRegistratorFCM.hostDefaultFirebaseApp( + ApplicationProvider.getApplicationContext(), + ) + + result shouldBe null + } + + test("ignores incomplete backend FCM params") { + val optionsSlot = slot() + val configStore = + MockHelper.configModelStore { + it.fcmParams.projectId = "backend-project" + } + val messaging = mockk() + every { messaging.token } returns Tasks.forResult("shared-token") + val namedApp = mockk() + every { namedApp.get(FirebaseMessaging::class.java) } returns messaging + + mockkStatic(FirebaseApp::class) + every { + FirebaseApp.initializeApp(any(), capture(optionsSlot), any()) + } returns namedApp + + val applicationService = mockk() + every { applicationService.appContext } returns ApplicationProvider.getApplicationContext() + + val registrator = + PushRegistratorFCM( + configStore, + applicationService, + mockk(relaxed = true), + mockk(relaxed = true), + defaultFirebaseApp = { null }, + ) + + val token = withContext(Dispatchers.IO) { registrator.getToken(SENDER_ID) } + + token shouldBe "shared-token" + optionsSlot.captured.projectId shouldBe "onesignal-shared-public" + } +}) diff --git a/examples/build.md b/examples/build.md index b8fe9df310..2f4d25f061 100644 --- a/examples/build.md +++ b/examples/build.md @@ -42,7 +42,7 @@ Two flavors are declared on the `default` dimension: | Flavor | Use case | |--------|----------| -| `gms` | Google Play Services / FCM — pulls `play-services-location`. OneSignal initializes its own Firebase app, so no Google Services plugin or `google-services.json` is needed. | +| `gms` | Google Play Services / FCM — pulls `play-services-location`. If the app ships `google-services.json` and applies the Google Services plugin, FCM registration uses that Firebase project. Otherwise the SDK falls back to OneSignal's shared Firebase app (legacy; does not work with Firebase Installation ID registration). | | `huawei` | Huawei HMS — applies `com.huawei.agconnect`, excludes the GMS transitive deps from the OneSignal artifact, and pulls `com.huawei.hms:push` + `com.huawei.hms:location`. | The Huawei plugin is selected at configuration time by inspecting `gradle.startParameter.taskRequests` so a clean `./gradlew tasks` doesn't require Huawei configuration.