Skip to content
Merged
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
21 changes: 11 additions & 10 deletions OneSignalSDK/detekt/detekt-baseline-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@
<ID>InstanceOfCheckForException:HttpClient.kt$HttpClient$t is UnknownHostException</ID>
<ID>LongMethod:ApplicationService.kt$ApplicationService$override suspend fun waitUntilSystemConditionsAvailable(): Boolean</ID>
<ID>LongMethod:ConfigModelStoreListener.kt$ConfigModelStoreListener$private fun fetchParams()</ID>
<ID>LongMethod:FeatureFlagsBackendService.kt$FeatureFlagsBackendService$override suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome</ID>
<ID>LongMethod:HttpClient.kt$HttpClient$private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse</ID>
<ID>LongMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List&lt;Operation>): ExecutionResponse</ID>
<ID>LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List&lt;Operation>, ): ExecutionResponse</ID>
Expand All @@ -196,7 +197,6 @@
<ID>LongParameterList:ICustomEventBackendService.kt$ICustomEventBackendService$( appId: String, onesignalId: String, externalId: String?, timestamp: Long, eventName: String, eventProperties: String?, metadata: CustomEventMetadata, )</ID>
<ID>LongParameterList:IDatabase.kt$IDatabase$( table: String, columns: Array&lt;String>? = null, whereClause: String? = null, whereArgs: Array&lt;String>? = null, groupBy: String? = null, having: String? = null, orderBy: String? = null, limit: String? = null, action: (ICursor) -> Unit, )</ID>
<ID>LongParameterList:IOutcomeEventsBackendService.kt$IOutcomeEventsBackendService$( appId: String, userId: String, subscriptionId: String, deviceType: String, direct: Boolean?, event: OutcomeEvent, )</ID>
<ID>LongParameterList:IParamsBackendService.kt$ParamsObject$( var googleProjectNumber: String? = null, var enterprise: Boolean? = null, var useIdentityVerification: Boolean? = null, var notificationChannels: JSONArray? = null, var firebaseAnalytics: Boolean? = null, var restoreTTLFilter: Boolean? = null, var clearGroupOnSummaryClick: Boolean? = null, var receiveReceiptEnabled: Boolean? = null, var disableGMSMissingPrompt: Boolean? = null, var unsubscribeWhenNotificationsDisabled: Boolean? = null, var locationShared: Boolean? = null, var requiresUserPrivacyConsent: Boolean? = null, var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, )</ID>
<ID>LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, )</ID>
<ID>LongParameterList:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$( private val _identityOperationExecutor: IdentityOperationExecutor, private val _application: IApplicationService, private val _deviceService: IDeviceService, private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, private val _subscriptionsModelStore: SubscriptionModelStore, private val _configModelStore: ConfigModelStore, private val _languageContext: ILanguageContext, )</ID>
<ID>LongParameterList:OutcomeEventsController.kt$OutcomeEventsController$( private val _session: ISessionService, private val _influenceManager: IInfluenceManager, private val _outcomeEventsCache: IOutcomeEventsRepository, private val _outcomeEventsPreferences: IOutcomeEventsPreferences, private val _outcomeEventsBackend: IOutcomeEventsBackendService, private val _configModelStore: ConfigModelStore, private val _identityModelStore: IdentityModelStore, private val _subscriptionManager: ISubscriptionManager, private val _deviceService: IDeviceService, private val _time: ITime, )</ID>
Expand Down Expand Up @@ -282,8 +282,8 @@
<ID>RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e</ID>
<ID>ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution</ID>
<ID>ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean</ID>
<ID>ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean</ID>
<ID>ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model?</ID>
<ID>ReturnCount:FeatureFlagsBackendService.kt$FeatureFlagsBackendService$override suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome</ID>
<ID>ReturnCount:HttpClient.kt$HttpClient$private suspend fun makeRequest( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse</ID>
<ID>ReturnCount:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List&lt;Operation>): ExecutionResponse</ID>
<ID>ReturnCount:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean</ID>
Expand All @@ -295,6 +295,8 @@
<ID>ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int?</ID>
<ID>ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long?</ID>
<ID>ReturnCount:Model.kt$Model$protected inline fun &lt;reified T : Enum&lt;T>> getOptEnumProperty(name: String): T?</ID>
<ID>ReturnCount:OneSignalImp.kt$OneSignalImp$@Suppress("TooGenericExceptionCaught") private fun internalInit( context: Context, appId: String?, ): Boolean</ID>
<ID>ReturnCount:OneSignalImp.kt$OneSignalImp$@Suppress("TooGenericExceptionCaught") private fun warnIfBlockingOnMainThread(operationName: String?)</ID>
<ID>ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation?</ID>
<ID>ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean</ID>
<ID>ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List&lt;Influence>, ): OutcomeEvent?</ID>
Expand All @@ -319,22 +321,27 @@
<ID>SwallowedException:DeviceService.kt$DeviceService$e: ClassNotFoundException</ID>
<ID>SwallowedException:DeviceService.kt$DeviceService$e: PackageManager.NameNotFoundException</ID>
<ID>SwallowedException:JSONUtils.kt$JSONUtils$t: Throwable</ID>
<ID>SwallowedException:OneSignalImp.kt$OneSignalImp$e: RuntimeException</ID>
<ID>SwallowedException:PermissionsActivity.kt$PermissionsActivity$e: ClassNotFoundException</ID>
<ID>SwallowedException:PreferencesService.kt$PreferencesService$ex: Exception</ID>
<ID>SwallowedException:PreferencesService.kt$PreferencesService$t: Throwable</ID>
<ID>SwallowedException:SyncJobService.kt$SyncJobService$e: Exception</ID>
<ID>SwallowedException:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$t: Throwable</ID>
<ID>ThrowsCount:OneSignalImp.kt$OneSignalImp$private suspend fun waitUntilInitInternal(operationName: String? = null)</ID>
<ID>TooGenericExceptionCaught:AndroidUtils.kt$AndroidUtils$e: Throwable</ID>
<ID>TooGenericExceptionCaught:DeviceUtils.kt$DeviceUtils$t: Throwable</ID>
<ID>TooGenericExceptionCaught:FeatureFlagsRefreshService.kt$FeatureFlagsRefreshService$e: Exception</ID>
<ID>TooGenericExceptionCaught:HttpClient.kt$HttpClient$e: Throwable</ID>
<ID>TooGenericExceptionCaught:HttpClient.kt$HttpClient$t: Throwable</ID>
<ID>TooGenericExceptionCaught:JSONUtils.kt$JSONUtils$t: Throwable</ID>
<ID>TooGenericExceptionCaught:Logging.kt$Logging$t: Throwable</ID>
<ID>TooGenericExceptionCaught:OneSignalDispatchers.kt$OneSignalDispatchers$e: Exception</ID>
<ID>TooGenericExceptionCaught:OneSignalImp.kt$OneSignalImp$e: Exception</ID>
<ID>TooGenericExceptionCaught:OperationRepo.kt$OperationRepo$e: Throwable</ID>
<ID>TooGenericExceptionCaught:PreferenceStoreFix.kt$PreferenceStoreFix$e: Throwable</ID>
<ID>TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$e: Throwable</ID>
<ID>TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$ex: Exception</ID>
<ID>TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$t: Throwable</ID>
<ID>TooGenericExceptionCaught:SyncJobService.kt$SyncJobService$e: Exception</ID>
<ID>TooGenericExceptionCaught:ThreadUtils.kt$e: Exception</ID>
<ID>TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$e: Throwable</ID>
Expand Down Expand Up @@ -413,10 +420,6 @@
<ID>UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResponse</ID>
<ID>UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResult</ID>
<ID>UndocumentedPublicClass:IOutcomeEvent.kt$IOutcomeEvent</ID>
<ID>UndocumentedPublicClass:IParamsBackendService.kt$FCMParamsObject</ID>
<ID>UndocumentedPublicClass:IParamsBackendService.kt$IParamsBackendService</ID>
<ID>UndocumentedPublicClass:IParamsBackendService.kt$InfluenceParamsObject</ID>
<ID>UndocumentedPublicClass:IParamsBackendService.kt$ParamsObject</ID>
<ID>UndocumentedPublicClass:IPreferencesService.kt$PreferenceOneSignalKeys</ID>
<ID>UndocumentedPublicClass:IPreferencesService.kt$PreferencePlayerPurchasesKeys</ID>
<ID>UndocumentedPublicClass:IPreferencesService.kt$PreferenceStores</ID>
Expand Down Expand Up @@ -549,8 +552,6 @@
<ID>UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun warn( message: String, throwable: Throwable? = null, )</ID>
<ID>UndocumentedPublicFunction:Logging.kt$Logging$fun addListener(listener: ILogListener)</ID>
<ID>UndocumentedPublicFunction:Logging.kt$Logging$fun removeListener(listener: ILogListener)</ID>
<ID>UndocumentedPublicFunction:LoginHelper.kt$LoginHelper$suspend fun login( externalId: String, jwtBearerToken: String? = null, )</ID>
<ID>UndocumentedPublicFunction:LogoutHelper.kt$LogoutHelper$fun logout()</ID>
<ID>UndocumentedPublicFunction:Model.kt$Model$fun &lt;T> setListProperty( name: String, value: List&lt;T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, )</ID>
<ID>UndocumentedPublicFunction:Model.kt$Model$fun &lt;T> setMapModelProperty( name: String, value: MapModel&lt;T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, )</ID>
<ID>UndocumentedPublicFunction:Model.kt$Model$fun &lt;T> setOptListProperty( name: String, value: List&lt;T>?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, )</ID>
Expand Down Expand Up @@ -610,9 +611,9 @@
<ID>UnusedPrivateMember:OperationRepo.kt$OperationRepo$private val _time: ITime</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'login'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'logout'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'login'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'logout'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before '$operationName'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before use")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed before '$operationName'")</ID>
<ID>UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.")</ID>
</CurrentIssues>
</SmellBaseline>
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.core.internal.http.impl.OptionalHeaders
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import org.json.JSONException
import org.json.JSONObject

internal class ParamsBackendService(
Expand All @@ -39,7 +40,23 @@ internal class ParamsBackendService(
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
}

val responseJson = JSONObject(response.payload!!)
// A 2xx response with a non-JSON body (e.g. an intercepting proxy / captive portal /
// CDN error page returning HTML) used to surface as an uncaught JSONException on the
// IO dispatcher and silently kill the params refresh loop. Convert it into a
// BackendException so the caller's existing retry/backoff path handles it like any
// other transient backend failure.
val payload = response.payload
val responseJson =
try {
JSONObject(payload ?: "")
} catch (ex: JSONException) {
Logging.warn(
Comment on lines +49 to +53
"ParamsBackendService.fetchParams: malformed (non-JSON) response payload, will retry. " +
"status=${response.statusCode}",
ex,
)
throw BackendException(response.statusCode, payload, response.retryAfterSeconds)
Comment on lines +43 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 Pre-existing issue worth flagging while in this area: the new try/catch only wraps the top-level JSONObject(payload ?: "") parse, but the same uncaught-JSONException-kills-the-refresh-loop failure mode can still occur from the field-extraction calls below it. The safeBool/safeString/safeLong/expandJSONObject helpers in JSONObjectExtensions.kt use strict getXxx (not optXxx) and throw on type mismatches, e.g. a 2xx body like {"outcomes":"forbidden"}, {"fcm":[]}, or {"enterp":"yes"}. Trigger plausibility is much narrower than the HTML/captive-portal case this PR targets (would require a server-side schema regression or a proxy that forges valid JSON with wrong types), but since the title frames the fix as protecting the loop from uncaught JSONException, it'd be a small extension to either widen the try/catch through the return ParamsObject(...) block or have the safe* helpers fall back to optXxx.

Extended reasoning...

Bug

After this PR, ParamsBackendService.fetchParams (ParamsBackendService.kt:43-58) catches JSONException only around the top-level JSONObject(payload ?: "") construction. Once that succeeds and the try/catch is exited, the rest of the function still touches responseJson through helpers that throw raw org.json.JSONException on type mismatches:

  • responseJson.expandJSONObject("outcomes")/("fcm")/("logging_config")JSONObjectExtensions.kt:148-155 calls getJSONObject(name), which throws if the value at the key is a String, Number, Array, or null.
  • responseJson.safeBool("enterp")/("fba")/etc — JSONObjectExtensions.kt:59-65 calls getBoolean(name), which throws on values like "yes", 1, or null.
  • responseJson.safeLong("oprepo_execution_interval")JSONObjectExtensions.kt:29-35 calls getLong(name), throws on non-coercible values.
  • responseJson.safeString(...) — calls getString(name), throws on JSONObject.NULL.

The caller ConfigModelStoreListener.fetchParams (ConfigModelStoreListener.kt:116) catches only BackendException. A JSONException propagates out of the do-while retry loop and out of the suspendifyOnIO coroutine — exactly the failure mode the PR's title claims to address ("handle non-JSON android_params.js response without killing refresh loop").

Step-by-step proof

Consider a 2xx response body {"outcomes":"forbidden"}:

  1. response.isSuccess is true → no BackendException thrown at line 41.
  2. payload = "{\"outcomes\":\"forbidden\"}".
  3. JSONObject(payload) succeeds — the body is syntactically valid JSON. The new try/catch is exited cleanly.
  4. responseJson.expandJSONObject("outcomes") checks has("outcomes") → true. Then calls this.getJSONObject("outcomes"). The value is "forbidden" (String), not a JSONObject → org.json.JSON.typeMismatch(...) throws JSONException.
  5. The exception propagates out of fetchParams. The caller's catch (ex: BackendException) does not match.
  6. The do-while loop terminates. suspendifyOnIO / suspendifyWithCompletion (ThreadUtils.kt) catches Exception only to log it — the coroutine still terminates and no further retries fire for the rest of the session.

Same outcome for {"fcm":[]}, {"enterp":"yes"}, {"enterp":1}, {"logging_config":null}, etc.

Why I'm filing as pre_existing

The refutation makes a fair scope argument: this hazard predates the PR (the safe* helpers and expandJSONObject already used strict getXxx on master), and the SDK-4478 incident is about HTML bodies — a top-level parse failure that the PR fully addresses. The trigger for the remaining hazard (a server returning syntactically-valid JSON with semantically-wrong types, or a proxy doing the same) is materially less plausible than the HTML interception case.

That said, the PR's title and description frame the fix as defending the refresh loop against uncaught JSONException generally, and the same exception class can still kill the loop from one line below the new catch. Worth surfacing as a code-review note while the author is in this code; not a blocker.

Suggested fix

Either approach is one-line-ish:

  1. Widen the try/catch to wrap everything that touches responseJson through the return ParamsObject(...) (i.e. move the return inside the try block, with the catch rethrowing BackendException as it does today).
  2. Or change the safe* helpers in JSONObjectExtensions.kt to use optInt/optLong/optBoolean/optString/optJSONObject — but that has wider blast radius across the codebase, so option 1 is more targeted.

}

// Process outcomes params
var influenceParams: InfluenceParamsObject? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,27 @@ class FeatureFlagsBackendServiceTests : FunSpec({
warns[0].entry.contains("""{"errors":["Forbidden"]}""") shouldBe true
}

// Companion regression to SDK-4478 on the params endpoint: a 2xx response with a
// completely non-JSON body (intercepting proxy like Burp, captive portal, CDN error
// HTML, etc.) must NOT throw and must NOT kill the refresh loop. It should fall
// through to Unavailable and log a WARN with a body snippet, exactly like the
// existing "wrong-shape JSON" path. FeatureFlagsJsonParser.parseSuccessful already
// catches Throwable internally; this test pins that contract so nobody regresses it.
test("200 with totally non-JSON body (HTML) returns Unavailable and does not throw") {
val htmlBody = "<html><head><title>Burp</title></head><body>intercepted</body></html>"
val http = mockk<IHttpClient>()
coEvery { http.get(any(), any()) } returns HttpResponse(200, htmlBody)

FeatureFlagsBackendService(http).fetchRemoteFeatureFlags("appId") shouldBe
RemoteFeatureFlagsFetchOutcome.Unavailable
val warns =
logsForService().filter {
it.level == LogLevel.WARN && it.entry.contains("not valid Turbine feature-flags JSON")
}
warns shouldHaveSize 1
warns[0].entry.contains("<html>") shouldBe true
}

test("body snippet caps long payloads at 200 chars") {
val longBody = "x".repeat(1_000)
val http = mockk<IHttpClient>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.onesignal.core.internal.backend.impl

import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.http.HttpResponse
import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.mockk.coEvery
import io.mockk.mockk

/**
* Regression coverage for SDK-4478: a 2xx response with a non-JSON body (e.g. an
* intercepting proxy or captive portal returning HTML) used to surface as an uncaught
* [org.json.JSONException] on the IO dispatcher and silently kill the params refresh
* loop. [ParamsBackendService.fetchParams] now converts those into [BackendException] so
* the caller's existing retry/backoff path handles them like any other transient failure.
*/
class ParamsBackendServiceTests : FunSpec({
var originalLogLevel: LogLevel = LogLevel.WARN

beforeEach {
// Logging.warn() ultimately routes through android.util.Log, which is not mocked
// in plain JVM unit tests. Silence it for the duration of these tests.
originalLogLevel = Logging.logLevel
Logging.logLevel = LogLevel.NONE
}

afterEach {
Logging.logLevel = originalLogLevel
}

test("malformed (HTML) 200 payload throws BackendException instead of JSONException") {
val http = mockk<IHttpClient>()
val htmlBody = "<html><head><title>Burp</title></head><body>intercepted</body></html>"
coEvery { http.get(any(), any()) } returns HttpResponse(200, htmlBody)

val ex =
shouldThrow<BackendException> {
ParamsBackendService(http).fetchParams("appId", null)
}

ex.statusCode shouldBe 200
ex.response shouldBe htmlBody
}

test("empty 200 payload throws BackendException instead of NullPointerException") {
val http = mockk<IHttpClient>()
coEvery { http.get(any(), any()) } returns HttpResponse(200, null)

val ex =
shouldThrow<BackendException> {
ParamsBackendService(http).fetchParams("appId", null)
}

ex.statusCode shouldBe 200
}

test("non-2xx response still throws BackendException with the original status code") {
val http = mockk<IHttpClient>()
coEvery { http.get(any(), any()) } returns HttpResponse(403, """{"errors":["Forbidden"]}""")

val ex =
shouldThrow<BackendException> {
ParamsBackendService(http).fetchParams("appId", null)
}

ex.statusCode shouldBe 403
ex.response shouldBe """{"errors":["Forbidden"]}"""
}

test("valid JSON payload is parsed and returns a populated ParamsObject") {
val http = mockk<IHttpClient>()
coEvery { http.get(any(), any()) } returns
HttpResponse(
200,
"""{"android_sender_id":"sender-123","enterp":true,"fcm":{"api_key":"k","app_id":"a","project_id":"p"}}""",
)

val params = ParamsBackendService(http).fetchParams("appId", null)

params shouldNotBe null
params.googleProjectNumber shouldBe "sender-123"
Comment on lines +83 to +86
params.enterprise shouldBe true
params.fcmParams.apiKey shouldBe "k"
params.fcmParams.appId shouldBe "a"
params.fcmParams.projectId shouldBe "p"
}
})
Loading
Loading