Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions OneSignalSDK/coverage/jacoco.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions OneSignalSDK/onesignal/notifications/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
Expand Down
10 changes: 10 additions & 0 deletions OneSignalSDK/onesignal/notifications/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
)

fun getToken(
senderId: String,
installationIdEnabled: () -> String,
legacyToken: () -> Task<String>,
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())
Comment on lines +71 to +72

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).

}

/**
* 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 <T> await(task: Task<T>): T {
try {
return Tasks.await(task)
} catch (e: ExecutionException) {
throw task.exception ?: e
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +63 to +77

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.

)
else ->
FcmFirebaseConfig(
credentials = sharedDefault,
source = FcmFirebaseConfig.Source.SHARED_DEFAULT,
reuseDefaultApp = false,
)
}
}
}
Loading