Skip to content
Closed
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
2 changes: 1 addition & 1 deletion OneSignalSDK/onesignal/notifications/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
Expand Down
6 changes: 6 additions & 0 deletions OneSignalSDK/onesignal/notifications/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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<Void>
},
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
}
}

Expand All @@ -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:<projectNumber>:android:<hash>", 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<String>,
private val registerForFid: () -> Task<Void>,
private val installationIdTask: () -> Task<String>,
) {
fun getToken(): String {
if (fidRegistrationEnabled) {
await(registerForFid())
return await(installationIdTask())
}

return await(legacyTokenTask())
}

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,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<String>>()
val registerForFid = mockk<() -> com.google.android.gms.tasks.Task<Void>>()
val installationIdTask = mockk<() -> com.google.android.gms.tasks.Task<String>>()
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<String>>()

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<IllegalStateException> {
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"
}
})
Loading