diff --git a/OneSignalSDK/detekt/detekt-baseline-core.xml b/OneSignalSDK/detekt/detekt-baseline-core.xml index 3f07ef1a1..690d3de2a 100644 --- a/OneSignalSDK/detekt/detekt-baseline-core.xml +++ b/OneSignalSDK/detekt/detekt-baseline-core.xml @@ -174,6 +174,7 @@ InstanceOfCheckForException:HttpClient.kt$HttpClient$t is UnknownHostException LongMethod:ApplicationService.kt$ApplicationService$override suspend fun waitUntilSystemConditionsAvailable(): Boolean LongMethod:ConfigModelStoreListener.kt$ConfigModelStoreListener$private fun fetchParams() + LongMethod:FeatureFlagsBackendService.kt$FeatureFlagsBackendService$override suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome LongMethod:HttpClient.kt$HttpClient$private suspend fun makeRequestIODispatcher( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse LongMethod:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse LongMethod:LoginUserOperationExecutor.kt$LoginUserOperationExecutor$private suspend fun createUser( createUserOperation: LoginUserOperation, operations: List<Operation>, ): ExecutionResponse @@ -196,7 +197,6 @@ LongParameterList:ICustomEventBackendService.kt$ICustomEventBackendService$( appId: String, onesignalId: String, externalId: String?, timestamp: Long, eventName: String, eventProperties: String?, metadata: CustomEventMetadata, ) LongParameterList:IDatabase.kt$IDatabase$( table: String, columns: Array<String>? = null, whereClause: String? = null, whereArgs: Array<String>? = null, groupBy: String? = null, having: String? = null, orderBy: String? = null, limit: String? = null, action: (ICursor) -> Unit, ) LongParameterList:IOutcomeEventsBackendService.kt$IOutcomeEventsBackendService$( appId: String, userId: String, subscriptionId: String, deviceType: String, direct: Boolean?, event: OutcomeEvent, ) - 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, ) LongParameterList:IUserBackendService.kt$IUserBackendService$( appId: String, aliasLabel: String, aliasValue: String, properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, ) 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, ) 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, ) @@ -282,8 +282,8 @@ RethrowCaughtException:OSDatabase.kt$OSDatabase$throw e ReturnCount:AppIdResolution.kt$fun resolveAppId( inputAppId: String?, configModel: ConfigModel, preferencesService: IPreferencesService, ): AppIdResolution ReturnCount:BackgroundManager.kt$BackgroundManager$override fun cancelRunBackgroundServices(): Boolean - ReturnCount:OneSignalImp.kt$OneSignalImp$private fun internalInit( context: Context, appId: String?, ): Boolean ReturnCount:ConfigModel.kt$ConfigModel$override fun createModelForProperty( property: String, jsonObject: JSONObject, ): Model? + ReturnCount:FeatureFlagsBackendService.kt$FeatureFlagsBackendService$override suspend fun fetchRemoteFeatureFlags(appId: String): RemoteFeatureFlagsFetchOutcome ReturnCount:HttpClient.kt$HttpClient$private suspend fun makeRequest( url: String, method: String?, jsonBody: JSONObject?, timeout: Int, headers: OptionalHeaders?, ): HttpResponse ReturnCount:IdentityOperationExecutor.kt$IdentityOperationExecutor$override suspend fun execute(operations: List<Operation>): ExecutionResponse ReturnCount:JSONUtils.kt$JSONUtils$fun compareJSONArrays( jsonArray1: JSONArray?, jsonArray2: JSONArray?, ): Boolean @@ -295,6 +295,8 @@ ReturnCount:Model.kt$Model$protected fun getOptIntProperty( name: String, create: (() -> Int?)? = null, ): Int? ReturnCount:Model.kt$Model$protected fun getOptLongProperty( name: String, create: (() -> Long?)? = null, ): Long? ReturnCount:Model.kt$Model$protected inline fun <reified T : Enum<T>> getOptEnumProperty(name: String): T? + ReturnCount:OneSignalImp.kt$OneSignalImp$@Suppress("TooGenericExceptionCaught") private fun internalInit( context: Context, appId: String?, ): Boolean + ReturnCount:OneSignalImp.kt$OneSignalImp$@Suppress("TooGenericExceptionCaught") private fun warnIfBlockingOnMainThread(operationName: String?) ReturnCount:OperationModelStore.kt$OperationModelStore$override fun create(jsonObject: JSONObject?): Operation? ReturnCount:OperationModelStore.kt$OperationModelStore$private fun isValidOperation(jsonObject: JSONObject): Boolean ReturnCount:OutcomeEventsController.kt$OutcomeEventsController$private suspend fun sendAndCreateOutcomeEvent( name: String, weight: Float, // Note: this is optional sessionTime: Long, influences: List<Influence>, ): OutcomeEvent? @@ -319,22 +321,27 @@ SwallowedException:DeviceService.kt$DeviceService$e: ClassNotFoundException SwallowedException:DeviceService.kt$DeviceService$e: PackageManager.NameNotFoundException SwallowedException:JSONUtils.kt$JSONUtils$t: Throwable + SwallowedException:OneSignalImp.kt$OneSignalImp$e: RuntimeException SwallowedException:PermissionsActivity.kt$PermissionsActivity$e: ClassNotFoundException SwallowedException:PreferencesService.kt$PreferencesService$ex: Exception + SwallowedException:PreferencesService.kt$PreferencesService$t: Throwable SwallowedException:SyncJobService.kt$SyncJobService$e: Exception SwallowedException:TrackGooglePurchase.kt$TrackGooglePurchase.Companion$t: Throwable ThrowsCount:OneSignalImp.kt$OneSignalImp$private suspend fun waitUntilInitInternal(operationName: String? = null) TooGenericExceptionCaught:AndroidUtils.kt$AndroidUtils$e: Throwable TooGenericExceptionCaught:DeviceUtils.kt$DeviceUtils$t: Throwable + TooGenericExceptionCaught:FeatureFlagsRefreshService.kt$FeatureFlagsRefreshService$e: Exception TooGenericExceptionCaught:HttpClient.kt$HttpClient$e: Throwable TooGenericExceptionCaught:HttpClient.kt$HttpClient$t: Throwable TooGenericExceptionCaught:JSONUtils.kt$JSONUtils$t: Throwable TooGenericExceptionCaught:Logging.kt$Logging$t: Throwable TooGenericExceptionCaught:OneSignalDispatchers.kt$OneSignalDispatchers$e: Exception + TooGenericExceptionCaught:OneSignalImp.kt$OneSignalImp$e: Exception TooGenericExceptionCaught:OperationRepo.kt$OperationRepo$e: Throwable TooGenericExceptionCaught:PreferenceStoreFix.kt$PreferenceStoreFix$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$e: Throwable TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$ex: Exception + TooGenericExceptionCaught:PreferencesService.kt$PreferencesService$t: Throwable TooGenericExceptionCaught:SyncJobService.kt$SyncJobService$e: Exception TooGenericExceptionCaught:ThreadUtils.kt$e: Exception TooGenericExceptionCaught:TrackGooglePurchase.kt$TrackGooglePurchase$e: Throwable @@ -413,10 +420,6 @@ UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResponse UndocumentedPublicClass:IOperationExecutor.kt$ExecutionResult UndocumentedPublicClass:IOutcomeEvent.kt$IOutcomeEvent - UndocumentedPublicClass:IParamsBackendService.kt$FCMParamsObject - UndocumentedPublicClass:IParamsBackendService.kt$IParamsBackendService - UndocumentedPublicClass:IParamsBackendService.kt$InfluenceParamsObject - UndocumentedPublicClass:IParamsBackendService.kt$ParamsObject UndocumentedPublicClass:IPreferencesService.kt$PreferenceOneSignalKeys UndocumentedPublicClass:IPreferencesService.kt$PreferencePlayerPurchasesKeys UndocumentedPublicClass:IPreferencesService.kt$PreferenceStores @@ -549,8 +552,6 @@ UndocumentedPublicFunction:Logging.kt$Logging$@JvmStatic fun warn( message: String, throwable: Throwable? = null, ) UndocumentedPublicFunction:Logging.kt$Logging$fun addListener(listener: ILogListener) UndocumentedPublicFunction:Logging.kt$Logging$fun removeListener(listener: ILogListener) - UndocumentedPublicFunction:LoginHelper.kt$LoginHelper$suspend fun login( externalId: String, jwtBearerToken: String? = null, ) - UndocumentedPublicFunction:LogoutHelper.kt$LogoutHelper$fun logout() UndocumentedPublicFunction:Model.kt$Model$fun <T> setListProperty( name: String, value: List<T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) UndocumentedPublicFunction:Model.kt$Model$fun <T> setMapModelProperty( name: String, value: MapModel<T>, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) UndocumentedPublicFunction:Model.kt$Model$fun <T> setOptListProperty( name: String, value: List<T>?, tag: String = ModelChangeTags.NORMAL, forceChange: Boolean = false, ) @@ -610,9 +611,9 @@ UnusedPrivateMember:OperationRepo.kt$OperationRepo$private val _time: ITime UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'login'") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("'initWithContext failed' before 'logout'") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'login'") - UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before 'logout'") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before '$operationName'") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw IllegalStateException("Must call 'initWithContext' before use") + UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed before '$operationName'") UseCheckOrError:OneSignalImp.kt$OneSignalImp$throw initFailureException ?: IllegalStateException("Initialization failed. Cannot proceed.") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt index dfaaa027d..aca355508 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt @@ -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( @@ -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( + "ParamsBackendService.fetchParams: malformed (non-JSON) response payload, will retry. " + + "status=${response.statusCode}", + ex, + ) + throw BackendException(response.statusCode, payload, response.retryAfterSeconds) + } // Process outcomes params var influenceParams: InfluenceParamsObject? = null diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendServiceTests.kt index c20b933b0..d7c4c1bee 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/FeatureFlagsBackendServiceTests.kt @@ -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 = "Burpintercepted" + val http = mockk() + 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("") shouldBe true + } + test("body snippet caps long payloads at 200 chars") { val longBody = "x".repeat(1_000) val http = mockk() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/ParamsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/ParamsBackendServiceTests.kt new file mode 100644 index 000000000..ce4d4788f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/backend/impl/ParamsBackendServiceTests.kt @@ -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() + val htmlBody = "Burpintercepted" + coEvery { http.get(any(), any()) } returns HttpResponse(200, htmlBody) + + val ex = + shouldThrow { + 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() + coEvery { http.get(any(), any()) } returns HttpResponse(200, null) + + val ex = + shouldThrow { + ParamsBackendService(http).fetchParams("appId", null) + } + + ex.statusCode shouldBe 200 + } + + test("non-2xx response still throws BackendException with the original status code") { + val http = mockk() + coEvery { http.get(any(), any()) } returns HttpResponse(403, """{"errors":["Forbidden"]}""") + + val ex = + shouldThrow { + 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() + 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" + params.enterprise shouldBe true + params.fcmParams.apiKey shouldBe "k" + params.fcmParams.appId shouldBe "a" + params.fcmParams.projectId shouldBe "p" + } +}) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt index d509b9494..47a0f3645 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt @@ -175,19 +175,45 @@ internal class WebViewManager( activity: Activity, jsonObject: JSONObject, ): Int { - return try { - val pageHeight = jsonObject.getJSONObject("rect").getInt("height") - var pxHeight = ViewUtils.dpToPx(pageHeight) - Logging.debug("getPageHeightData:pxHeight: $pxHeight") - val maxPxHeight = getWebViewMaxSizeY(activity) - if (pxHeight > maxPxHeight) { - pxHeight = maxPxHeight - Logging.debug("getPageHeightData:pxHeight is over screen max: $maxPxHeight") - } - pxHeight - } catch (e: JSONException) { - Logging.error("pageRectToViewHeight could not get page height", e) - -1 + // SDK-4494: avoid throw-then-catch on `rect` being absent. The IAM HTML's + // `getPageMetaData()` can legitimately return a payload without `rect` for + // benign/recoverable reasons (e.g. JS not yet defined when the activity + // rotates, custom IAM template, partial metadata). The previous + // `getJSONObject("rect")` raised `JSONException` which we caught and logged + // at ERROR with a full stack trace, flooding OTel/Datadog with non-actionable + // alerts. Use `optJSONObject` and `optInt` so missing fields are a structured + // null/sentinel instead, and downgrade the log to a single WARN line. + val rect = jsonObject.optJSONObject("rect") + val pageHeight = rect?.optInt("height", -1) ?: -1 + if (pageHeight < 0) { + Logging.warn( + "pageRectToViewHeight could not get page height (missing/invalid 'rect.height'); " + + "snippet=${bodySnippet(jsonObject.toString())}", + ) + return -1 + } + var pxHeight = ViewUtils.dpToPx(pageHeight) + Logging.debug("getPageHeightData:pxHeight: $pxHeight") + val maxPxHeight = getWebViewMaxSizeY(activity) + if (pxHeight > maxPxHeight) { + pxHeight = maxPxHeight + Logging.debug("getPageHeightData:pxHeight is over screen max: $maxPxHeight") + } + return pxHeight + } + + /** + * Trim [body] to a short, single-line snippet safe for logcat / OTel. See + * SDK-4494 - we only want enough context to debug shape mismatches without + * dumping the full WebView payload into log pipelines. + */ + private fun bodySnippet(body: String?): String { + if (body.isNullOrEmpty()) return "" + val flattened = body.replace('\n', ' ').replace('\r', ' ') + return if (flattened.length <= LOG_BODY_SNIPPET_MAX_CHARS) { + flattened + } else { + flattened.take(LOG_BODY_SNIPPET_MAX_CHARS) + "…" } } @@ -233,6 +259,19 @@ internal class WebViewManager( } webView!!.evaluateJavascript(GET_PAGE_META_DATA_JS_FUNCTION) { value -> + // SDK-4494: `evaluateJavascript` returns the JSON-encoded result of the + // expression. When the JS function is undefined or returns `undefined` + // (e.g. WebView not fully loaded yet) the callback receives the literal + // string "null", which `JSONObject(...)` rejects. Bail out early instead + // of throwing+catching, and route any remaining surprise through Logging + // (was previously `e.printStackTrace()`, which bypassed our log pipeline). + if (value.isNullOrBlank() || value == "null") { + Logging.warn( + "calculateHeightAndShowWebViewAfterNewActivity: empty/null page metadata " + + "from WebView; skipping height update", + ) + return@evaluateJavascript + } try { val pagePxHeight = pageRectToViewHeight(activity, JSONObject(value)) @@ -240,7 +279,11 @@ internal class WebViewManager( showMessageView(pagePxHeight) } } catch (e: JSONException) { - e.printStackTrace() + Logging.warn( + "calculateHeightAndShowWebViewAfterNewActivity: could not parse page metadata; " + + "snippet=${bodySnippet(value)}", + e, + ) } } } @@ -463,6 +506,12 @@ internal class WebViewManager( companion object { private val MARGIN_PX_SIZE = ViewUtils.dpToPx(24) + + // SDK-4494: cap the body snippet included in WARN logs so a malformed/large + // WebView payload can't blow up the OTel log entry. Same pattern as + // FeatureFlagsBackendService. + private const val LOG_BODY_SNIPPET_MAX_CHARS = 200 + const val JS_OBJ_NAME = "OSAndroid" const val GET_PAGE_META_DATA_JS_FUNCTION = "getPageMetaData()" const val SET_SAFE_AREA_INSETS_JS_FUNCTION = "setSafeAreaInsets(%s)"