Skip to content

fix: [SDK-4946] prefer google-services.json over the shared FCM project - #2725

Draft
abdulraqeeb33 wants to merge 1 commit into
mainfrom
ar/fcm-google-services-project-f536
Draft

fix: [SDK-4946] prefer google-services.json over the shared FCM project#2725
abdulraqeeb33 wants to merge 1 commit into
mainfrom
ar/fcm-google-services-project-f536

Conversation

@abdulraqeeb33

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Prefer the host app's google-services.json for FCM registration so we stop mixing the dashboard sender id with OneSignal's shared Firebase project (onesignal-shared-public / 754795614042).

Details

Motivation

Google now requires the FCM sender id, project id, application id, and API key to belong to the same Firebase project, and Firebase Installation IDs are issued per project. The Android SDK has historically initialized its own FirebaseApp with OneSignal's shared public credentials and the customer's sender id at runtime. That mixed configuration is rejected for Installation ID registration (ApiException: 8) and is the dead end behind SDK-4946.

The SDK can get off the shared project by reading the file Google already asks every app to ship: google-services.json (compiled into string resources by the google-services Gradle plugin, then loaded via FirebaseApp.initializeApp(context)).

Scope

FCM registration now picks one consistent project, in this order:

  1. Host google-services.json — reuse the default FirebaseApp when its sender id matches the OneSignal dashboard.
  2. Backend fcm params from android_params.js, when complete. These are now read at registration time, not snapshotted in the PushRegistratorFCM constructor (so they were previously ignored if the registrator was created before params hydration).
  3. OneSignal shared project (onesignal-shared-public / 754795614042) as a last-resort fallback for apps that never added google-services.json. This still will not work with Installation ID registration.

When the legacy getToken() API is disabled (firebase_messaging_installation_id_enabled), registration falls back to FirebaseMessaging.register() against that same host project and returns the Firebase Installation ID. Apps that opt in still need their own google-services.json whose sender id matches the dashboard.

If google-services.json is present but its sender id does not match the dashboard, the SDK logs a warning and does not use it (so an Analytics project can still coexist with a different OneSignal FCM project via backend/shared credentials).

No public API changes. Apps without google-services.json keep the legacy shared-project path.

Testing

Unit testing

  • FcmFirebaseConfigResolverTests covers host match, host mismatch, incomplete host/backend credentials, backend params, and the shared fallback.
  • FCMTokenProviderTests covers legacy tokens, Installation ID registration, missing default app / sender mismatch diagnostics, reflection, and error propagation.
  • PushRegistratorFCMTests covers reusing the host app, backend params (including reading them after construction), shared fallback, sender mismatch, Installation ID wiring, and FirebaseOptions mapping.

:OneSignal:notifications:testDebugUnitTest, Spotless, and Detekt pass locally.

Manual testing

Not run on a device in this environment. The registration branches are covered with unit tests. To verify on a device:

  1. Drop a google-services.json whose sender id matches the OneSignal dashboard into the example app, apply the google-services plugin, and confirm logs say registration is using that project and that a push arrives.
  2. Enable firebase_messaging_installation_id_enabled and confirm the subscription token is a Firebase Installation ID and delivery still works.
  3. Remove google-services.json and confirm the shared-project fallback still returns a legacy FCM token when the Installation ID flag is off.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing — FCM registration source of truth
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible — see Manual testing

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item
Open in Web Open in Cursor 

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 <abdulraqeeb33@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • FCMTokenProvider.kt: 37/37 touched executable lines (100.0%) (114 touched lines in diff)
  • FcmFirebaseConfigResolver.kt: 34/34 touched executable lines (100.0%) (87 touched lines in diff)
  • PushRegistratorFCM.kt: 66/67 touched executable lines (98.5%) (165 touched lines in diff)

Overall (aggregate gate)

137/138 touched executable lines covered (99.3% — requires ≥ 80%)

📥 View workflow run

@abdulraqeeb33
abdulraqeeb33 marked this pull request as ready for review August 25, 2026 18:46
@abdulraqeeb33
abdulraqeeb33 requested a review from a team as a code owner August 25, 2026 18:46

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-model review of the FCM project-selection change (Claude Opus 5, GPT 5.6 Sol, Cursor Grok 4.6).

The host-app preference order is sound: matching google-services.json → complete backend fcm params → shared project. The mixed sender + onesignal-shared-public config is the right thing to stop doing for Installation ID registration.

Act on

  1. FID vs what register() actually minted. After FirebaseMessaging.register(), the SDK always uploads FirebaseInstallations.id. On Play Services below the V1 threshold, register() silently does a legacy registration and the FID is not a sendable push token — a SUBSCRIBED subscription that never gets messages. All three reviewers flagged this.
  2. BACKEND is not a consistency check. Completeness (non-blank fields) is treated as “real customer project.” Dashboard sender is then paired with those fields, and FID is allowed on any non-SHARED_DEFAULT source. If android_params.js still ships the shared public project — or any applicationId whose project number ≠ dashboard sender — this recreates the mixed config SDK-4946 is about, now on the FID path.

Consider
3. initFirebaseApp is unsynchronized check-then-act; a second initializeApp(..., ONESIGNAL_SDK_FCM_APP_NAME) throws and never recovers. First success also freezes config for the process.
4. FirebaseApp.initializeApp(context) creates the host default app (Analytics/Crashlytics/data-collection start) when FirebaseInitProvider was removed. Prefer a read-only lookup.
5. No PushRegistratorFCM test drives a successful register() + installation id; the only FID-path test asserts the 25.1.0 missing-method failure.

Noted / dismissed

  • Fallback keyed on English IllegalStateException text ("API disabled" + "register()") — brittle; the manifest flag is the stable signal.
  • Reusing the default FirebaseApp changes the stored token and couples it to the host app’s deleteToken()/unregister() lifecycle — changelog-worthy.
  • Gradle require '[23.0.8, 24.0.99]' blocking 25.x — dismissed: require can still upgrade via conflict resolution; strictly would not.
  • Tasks.await without timeout, gcmSenderId vs Firebase’s applicationId fallback, JaCoCo excludes = clobber — nits / pre-existing, not blocking.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Comment on lines +71 to +72
await(registration.register())
return await(registration.installationId())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical (3/3 models): register() is Task<Void>; the value uploaded is a separate FirebaseInstallations.id hop, not the identifier FCM just registered.

On Play Services below the V1 threshold (GMS_VERSION_Y2026W12 / 261200000 in firebase-messaging 25.1), FirebaseMessaging.register() silently takes the legacy getToken path. getToken() still throws “API disabled” from the manifest flag alone, so this branch runs, register() succeeds with a normal FCM token, and the SDK reports an FID that was never registered as a send target. Result: SUBSCRIBED with an identifier that cannot receive pushes, with no log distinguishing it.

Do not synthesize the token independently of what FCM produced. Gate the FID path on Play Services support, or use the identifier FCM actually registered (the value blockingRegister / onRegistered delivers).

Comment on lines +63 to +77
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning (3/3 models): isComplete only checks non-blank strings. The dashboard sender is injected in backendCredentials() and is never compared to the project number embedded in applicationId (1:<sender>:android:…).

firebaseAppForInstallationId() then allows FID registration for any non-SHARED_DEFAULT source. If android_params.js still returns the shared public project (onesignal-shared-public / 1:754795614042:android:…) as a complete fcm object, this is classified BACKEND, not SHARED_DEFAULT, and FID runs against the mixed config this PR exists to stop.

Treat backend params that match the shared defaults — or whose applicationId project number ≠ dashboard sender — as SHARED_DEFAULT. Only allow FID on GOOGLE_SERVICES, or on BACKEND after proving the four fields belong to one non-shared project.

}

private fun initFirebaseApp(senderId: String) {
if (firebaseApp != null) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning (3/3 models): Unsynchronized check-then-act on a plain var. DeviceRegistrationListener.start() and onModelReplaced(HYDRATE) can both call getToken on IO. Two threads can both pass this null check and call FirebaseApp.initializeApp(..., ONESIGNAL_SDK_FCM_APP_NAME); the second hits “already exists” and unlike hostDefaultFirebaseApp there is no getInstance(FCM_APP_NAME) recovery.

The first success also freezes firebaseApp / resolvedSource for the process, so a later HYDRATE that brings complete backend params cannot re-resolve.

Serialize init, recover from an existing named app, and/or cache a credentials fingerprint so sender / fcmParams changes are picked up.

*/
internal fun hostDefaultFirebaseApp(context: Context): FirebaseApp? {
return try {
FirebaseApp.initializeApp(context)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning (2/3 models): This is not a read-only lookup. FirebaseApp.initializeApp(context) creates the default app from plugin string resources and starts every Firebase component (initializeAllApis()), including when the sender later mismatches and OneSignal builds a named app anyway.

Apps that remove FirebaseInitProvider (tools:node="remove") to gate Analytics/Crashlytics/data collection behind consent now get a default FirebaseApp from OneSignal on a background thread at push-registration time, with no opt-out.

Prefer FirebaseApp.getApps(context).firstOrNull { it.name == DEFAULT_APP_NAME } / getInstance(), and only call initializeApp(context) if creating the host default app is an intentional, documented side effect.

@fadi-george

Copy link
Copy Markdown
Contributor

Host google-services.json match path looks right. Three things that can still pick the wrong project:

  1. First getToken pins firebaseApp for the process. Constructor snapshot is gone, but resolution is still snapshotted one layer later. start() can register against cached/incomplete fcmParams before android_params.js hydrates, and the later retry cannot switch off SHARED_DEFAULT. Tests only cover params arriving after construction and before the first getToken.

  2. Installation ID can use a default app the resolver already rejected. On SHARED_DEFAULT, firebaseAppForInstallationId() falls back to defaultFirebaseApp and only rechecks sender id. An incomplete host app (matching sender, missing projectId) is dropped for push, then reused for FID.

  3. BACKEND can still mix projects, then run FID on that mix. backendCredentials() always stamps the dashboard sender onto fcmParams. Completeness is four non-blank strings. Any non-SHARED_DEFAULT source is treated as safe for FID. If android_params.js still ships the shared public project as a complete fcm object, this is classified as BACKEND and hits the same mixed config the ticket is about.

Restrict FID to GOOGLE_SERVICES (or require backend app id / project number to match the dashboard sender and not the shared defaults). Don't pin the first resolution. Drive II selection with the same predicate as the resolver.

@abdulraqeeb33
abdulraqeeb33 marked this pull request as draft August 26, 2026 15:24
@fadi-george

fadi-george commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Id make this a separate linear ticket to not mix approaches for SDK-4946.
So a new branch and remake the pr.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants