diff --git a/android/build.gradle b/android/build.gradle
index 05e0d2d1..ca5b85c0 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -3,6 +3,24 @@ plugins {
id "org.jetbrains.kotlin.android"
}
+def omSdkLocalMavenRepo = uri("${project.projectDir}/local-maven")
+
+rootProject.allprojects {
+ repositories {
+ maven {
+ url omSdkLocalMavenRepo
+ }
+ }
+}
+
+repositories {
+ google()
+ mavenCentral()
+ maven {
+ url omSdkLocalMavenRepo
+ }
+}
+
android {
namespace "so.kontext.sdk.flutter"
compileSdk = 34
@@ -27,4 +45,5 @@ android {
dependencies {
implementation "androidx.webkit:webkit:1.8.0"
implementation "com.google.android.gms:play-services-ads-identifier:18.0.1"
+ implementation "iab:omsdk-android:1.6.4"
}
diff --git a/android/local-maven/iab/omsdk-android/1.6.4/omsdk-android-1.6.4.aar b/android/local-maven/iab/omsdk-android/1.6.4/omsdk-android-1.6.4.aar
new file mode 100644
index 00000000..4f69f041
Binary files /dev/null and b/android/local-maven/iab/omsdk-android/1.6.4/omsdk-android-1.6.4.aar differ
diff --git a/android/local-maven/iab/omsdk-android/1.6.4/omsdk-android-1.6.4.pom b/android/local-maven/iab/omsdk-android/1.6.4/omsdk-android-1.6.4.pom
new file mode 100644
index 00000000..e2676bcd
--- /dev/null
+++ b/android/local-maven/iab/omsdk-android/1.6.4/omsdk-android-1.6.4.pom
@@ -0,0 +1,8 @@
+
+
+ 4.0.0
+ iab
+ omsdk-android
+ 1.6.4
+ aar
+
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/KontextInAppWebView.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/KontextInAppWebView.kt
index 15ea39f4..8fe229d3 100644
--- a/android/src/main/kotlin/so/kontext/sdk/flutter/KontextInAppWebView.kt
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/KontextInAppWebView.kt
@@ -6,6 +6,7 @@ import android.graphics.Color
import android.os.Build
import android.os.Handler
import android.os.Looper
+import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
@@ -24,6 +25,10 @@ import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.platform.PlatformView
+import so.kontext.sdk.flutter.omsdk.OMConstants
+import so.kontext.sdk.flutter.omsdk.OMCreativeType
+import so.kontext.sdk.flutter.omsdk.WebViewOMLifecycle
+
private const val CHANNEL_PREFIX = "kontext_flutter_sdk/in_app_webview/"
private const val JAVASCRIPT_BRIDGE_NAME = "flutter_inappwebview"
private const val MAX_BYPASS_MAIN_FRAME_LOADS = 100
@@ -53,10 +58,22 @@ internal class KontextInAppWebView(
private val initialUrl = readInitialUrl(creationParams)
private val settings = readSettings(creationParams)
private val initialUserScripts = readUserScripts(creationParams)
- private var hasLoadedInitialUrl = false
-
- private val bypassMainFrameLoads = linkedSetOf()
private val documentStartSupported = WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
+ private val openMeasurementJavascript = if (documentStartSupported) {
+ loadOpenMeasurementJavaScript(context)
+ } else {
+ null
+ }
+ private val omLifecycle = WebViewOMLifecycle(
+ webView = webView,
+ initialContentUrl = initialUrl,
+ initialCreativeType = readInitialOmCreativeType(creationParams),
+ canUseOpenMeasurement = ::canUseOpenMeasurement,
+ logUnsupportedOpenMeasurement = ::logUnsupportedOpenMeasurement,
+ )
+ private val bypassMainFrameLoads = linkedSetOf()
+
+ private var hasLoadedInitialUrl = false
private val bridgeScript = """
(function() {
@@ -129,10 +146,12 @@ internal class KontextInAppWebView(
override fun getView(): View = webView
override fun dispose() {
+ omLifecycle.finish()
channel.setMethodCallHandler(null)
- webView.stopLoading()
webView.removeJavascriptInterface(JAVASCRIPT_BRIDGE_NAME)
- webView.destroy()
+ if (!omLifecycle.dispose()) {
+ destroyWebViewImmediately()
+ }
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@@ -155,6 +174,33 @@ internal class KontextInAppWebView(
result.success(null)
}
}
+ "configureOpenMeasurement" -> {
+ mainHandler.post {
+ omLifecycle.configure(call.argument("creativeType"))
+ result.success(null)
+ }
+ }
+ "startOpenMeasurementSession" -> {
+ mainHandler.post {
+ omLifecycle.requestStart()
+ result.success(null)
+ }
+ }
+ "logOpenMeasurementError" -> {
+ mainHandler.post {
+ omLifecycle.logError(
+ errorType = call.argument("errorType"),
+ message = call.argument("message"),
+ )
+ result.success(null)
+ }
+ }
+ "finishOpenMeasurementSession" -> {
+ mainHandler.post {
+ omLifecycle.finish()
+ result.success(null)
+ }
+ }
else -> result.notImplemented()
}
}
@@ -192,6 +238,7 @@ internal class KontextInAppWebView(
}
if (documentStartSupported) {
+ openMeasurementJavascript?.let(::addDocumentStartScript)
addDocumentStartScript(bridgeScript)
// To avoid Android WebView loader for videos, inject JS code with 1x1 transparent pixel.
addDocumentStartScript(posterStartScript)
@@ -260,6 +307,7 @@ internal class KontextInAppWebView(
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
super.onPageStarted(view, url, favicon)
+ omLifecycle.markPageStarted(url)
if (!documentStartSupported) {
// Older WebView versions do not support document-start scripts, so this
// fallback injects asynchronously and may still lose the race to early
@@ -277,6 +325,7 @@ internal class KontextInAppWebView(
.filter { it.injectionTime == "AT_DOCUMENT_END" }
.forEach { evaluateJavascript(it.source) }
evaluateJavascript(PLATFORM_READY_SCRIPT)
+ omLifecycle.markPageFinished(url)
}
override fun onReceivedError(
@@ -342,6 +391,7 @@ internal class KontextInAppWebView(
return
}
hasLoadedInitialUrl = true
+ omLifecycle.markPageStarted(initialUrl)
if (!initialUrl.isNullOrBlank()) {
webView.loadUrl(initialUrl)
}
@@ -395,6 +445,30 @@ internal class KontextInAppWebView(
""".trimIndent()
}
+ private fun canUseOpenMeasurement(): Boolean = documentStartSupported && openMeasurementJavascript != null
+
+ private fun logUnsupportedOpenMeasurement() {
+ Log.w(
+ OMConstants.logTag,
+ if (!documentStartSupported) {
+ "DOCUMENT_START_SCRIPT not supported, OM SDK disabled for this WebView"
+ } else {
+ "OM SDK JavaScript resource could not be loaded, OM SDK disabled for this WebView"
+ }
+ )
+ }
+
+ private fun destroyWebViewImmediately() {
+ try {
+ (webView.parent as? ViewGroup)?.removeView(webView)
+ webView.stopLoading()
+ webView.loadUrl("about:blank")
+ webView.destroy()
+ } catch (exception: Throwable) {
+ Log.w(OMConstants.logTag, "Immediate WebView destroy failed", exception)
+ }
+ }
+
private fun evaluateJavascript(source: String) {
webView.evaluateJavascript(source, null)
}
@@ -464,6 +538,10 @@ private data class AndroidUserScript(
val injectionTime: String,
)
+private fun readInitialOmCreativeType(creationParams: Map?): OMCreativeType? {
+ return OMCreativeType.fromRawValue(creationParams?.get("initialOmCreativeType") as? String)
+}
+
private fun readInitialUrl(creationParams: Map?): String? {
val initialRequest = creationParams?.get("initialUrlRequest") as? Map<*, *>
return initialRequest?.get("url") as? String
@@ -494,6 +572,17 @@ private fun readUserScripts(creationParams: Map?): List "ERROR"
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt
index e83ad008..b32bd180 100644
--- a/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/KontextSdkPlugin.kt
@@ -1,6 +1,7 @@
package so.kontext.sdk.flutter
import io.flutter.embedding.engine.plugins.FlutterPlugin
+import so.kontext.sdk.flutter.omsdk.OMSDKPlugin
class KontextSdkPlugin : FlutterPlugin {
private val advertisingId = AdvertisingIdPlugin()
@@ -10,6 +11,7 @@ class KontextSdkPlugin : FlutterPlugin {
private val inAppWebView = KontextInAppWebViewPlugin()
private val network = DeviceNetworkPlugin()
private val os = OperationSystemPlugin()
+ private val omsdk = OMSDKPlugin()
private val power = DevicePowerPlugin()
private val tcf = TransparencyConsentFramework()
@@ -21,6 +23,7 @@ class KontextSdkPlugin : FlutterPlugin {
inAppWebView.onAttachedToEngine(binding)
network.onAttachedToEngine(binding)
os.onAttachedToEngine(binding)
+ omsdk.onAttachedToEngine(binding)
power.onAttachedToEngine(binding)
tcf.onAttachedToEngine(binding)
}
@@ -32,6 +35,7 @@ class KontextSdkPlugin : FlutterPlugin {
hardware.onDetachedFromEngine(binding)
network.onDetachedFromEngine(binding)
os.onDetachedFromEngine(binding)
+ omsdk.onDetachedFromEngine(binding)
power.onDetachedFromEngine(binding)
tcf.onDetachedFromEngine(binding)
}
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMConstants.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMConstants.kt
new file mode 100644
index 00000000..ebc535d7
--- /dev/null
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMConstants.kt
@@ -0,0 +1,9 @@
+package so.kontext.sdk.flutter.omsdk
+
+internal object OMConstants {
+ const val channelName = "kontext_flutter_sdk/omsdk"
+ const val integrationVersion = "1.0.0"
+ const val logTag = "Kontext SDK"
+ const val partnerName = "Kontextso"
+ const val retentionIntervalMillis = 1000L
+}
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMManager.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMManager.kt
new file mode 100644
index 00000000..e1644e51
--- /dev/null
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMManager.kt
@@ -0,0 +1,96 @@
+package so.kontext.sdk.flutter.omsdk
+
+import android.content.Context
+import android.util.Log
+import android.webkit.WebView
+import com.iab.omid.library.kontextso.Omid
+import com.iab.omid.library.kontextso.adsession.AdSession
+import com.iab.omid.library.kontextso.adsession.AdSessionConfiguration
+import com.iab.omid.library.kontextso.adsession.AdSessionContext
+import com.iab.omid.library.kontextso.adsession.CreativeType
+import com.iab.omid.library.kontextso.adsession.ImpressionType
+import com.iab.omid.library.kontextso.adsession.Owner
+import com.iab.omid.library.kontextso.adsession.Partner
+
+internal enum class OMCreativeType(val rawValue: String) {
+ DISPLAY("display"),
+ VIDEO("video");
+
+ companion object {
+ fun fromRawValue(value: String?): OMCreativeType? = values().firstOrNull { it.rawValue == value }
+ }
+}
+
+internal object OMManager {
+ private val partner: Partner? by lazy {
+ try {
+ Partner.createPartner(OMConstants.partnerName, OMConstants.integrationVersion)
+ } catch (exception: IllegalArgumentException) {
+ Log.e(OMConstants.logTag, "OM partner creation failed", exception)
+ null
+ }
+ }
+
+ fun activate(context: Context): Boolean {
+ if (Omid.isActive()) {
+ return true
+ }
+
+ return try {
+ Omid.activate(context.applicationContext)
+ Omid.isActive()
+ } catch (exception: IllegalArgumentException) {
+ Log.e(OMConstants.logTag, "OM SDK activation failed", exception)
+ false
+ } catch (exception: IllegalStateException) {
+ Log.e(OMConstants.logTag, "OM SDK activation failed", exception)
+ false
+ }
+ }
+
+ fun createSession(
+ webView: WebView,
+ contentUrl: String?,
+ creativeType: OMCreativeType,
+ ): OMSession? {
+ if (!Omid.isActive()) {
+ Log.w(OMConstants.logTag, "OM session creation skipped because the SDK is not active")
+ return null
+ }
+
+ val partner = partner
+ if (partner == null) {
+ return null
+ }
+
+ return try {
+ val context = AdSessionContext.createHtmlAdSessionContext(
+ partner,
+ webView,
+ contentUrl,
+ "",
+ )
+ val (omCreativeType, mediaEventsOwner) = when (creativeType) {
+ OMCreativeType.DISPLAY -> CreativeType.HTML_DISPLAY to Owner.NONE
+ OMCreativeType.VIDEO -> CreativeType.VIDEO to Owner.JAVASCRIPT
+ }
+ val configuration = AdSessionConfiguration.createAdSessionConfiguration(
+ omCreativeType,
+ ImpressionType.BEGIN_TO_RENDER,
+ Owner.JAVASCRIPT,
+ mediaEventsOwner,
+ false,
+ )
+ val session = AdSession.createAdSession(configuration, context).apply {
+ registerAdView(webView)
+ }
+ OMSession(session = session, webView = webView)
+ } catch (exception: IllegalArgumentException) {
+ Log.e(OMConstants.logTag, "OM session creation failed", exception)
+ null
+ } catch (exception: IllegalStateException) {
+ Log.e(OMConstants.logTag, "OM session creation failed", exception)
+ null
+ }
+ }
+}
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMRetentionPool.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMRetentionPool.kt
new file mode 100644
index 00000000..5fbf4742
--- /dev/null
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMRetentionPool.kt
@@ -0,0 +1,24 @@
+package so.kontext.sdk.flutter.omsdk
+
+import android.os.Handler
+import android.os.Looper
+import java.util.UUID
+
+internal object OMRetentionPool {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val retainedWebViews = mutableMapOf()
+
+ fun retain(
+ webView: OMRetainedWebView,
+ delayMillis: Long = OMConstants.retentionIntervalMillis,
+ ) {
+ val id = UUID.randomUUID().toString()
+ retainedWebViews[id] = webView
+ mainHandler.postDelayed(
+ {
+ retainedWebViews.remove(id)?.destroy()
+ },
+ delayMillis,
+ )
+ }
+}
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMSDKPlugin.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMSDKPlugin.kt
new file mode 100644
index 00000000..69493b24
--- /dev/null
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMSDKPlugin.kt
@@ -0,0 +1,28 @@
+package so.kontext.sdk.flutter.omsdk
+
+import android.content.Context
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+
+class OMSDKPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
+ private lateinit var channel: MethodChannel
+ private lateinit var context: Context
+
+ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ context = binding.applicationContext
+ channel = MethodChannel(binding.binaryMessenger, OMConstants.channelName)
+ channel.setMethodCallHandler(this)
+ }
+
+ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ when (call.method) {
+ "activate" -> result.success(OMManager.activate(context))
+ else -> result.notImplemented()
+ }
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ channel.setMethodCallHandler(null)
+ }
+}
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMSession.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMSession.kt
new file mode 100644
index 00000000..7c9a9dfe
--- /dev/null
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/OMSession.kt
@@ -0,0 +1,54 @@
+package so.kontext.sdk.flutter.omsdk
+
+import android.util.Log
+import android.view.ViewGroup
+import android.webkit.WebView
+import com.iab.omid.library.kontextso.adsession.AdSession
+import com.iab.omid.library.kontextso.adsession.ErrorType
+
+internal class OMSession(
+ private val session: AdSession,
+ private val webView: WebView,
+) {
+ fun start() {
+ session.start()
+ }
+
+ fun retire() {
+ try {
+ webView.evaluateJavascript("window.postMessage({ type: 'retire-iframe' }, '*');", null)
+ } catch (exception: Throwable) {
+ Log.w(OMConstants.logTag, "OM retire message failed", exception)
+ }
+ }
+
+ fun finish() {
+ session.finish()
+ }
+
+ fun logError(errorType: String?, message: String?) {
+ val omErrorType = if (errorType == "video") ErrorType.VIDEO else ErrorType.GENERIC
+ try {
+ session.error(omErrorType, message ?: "unknown")
+ } catch (exception: IllegalStateException) {
+ Log.e(OMConstants.logTag, "OM error logging failed", exception)
+ }
+ }
+
+ fun retainedWebView(): OMRetainedWebView = OMRetainedWebView(webView)
+}
+
+internal class OMRetainedWebView(
+ private val webView: WebView,
+) {
+ fun destroy() {
+ try {
+ (webView.parent as? ViewGroup)?.removeView(webView)
+ webView.stopLoading()
+ webView.loadUrl("about:blank")
+ webView.destroy()
+ } catch (exception: Throwable) {
+ Log.w(OMConstants.logTag, "Deferred WebView destroy failed", exception)
+ }
+ }
+}
diff --git a/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/WebViewOMLifecycle.kt b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/WebViewOMLifecycle.kt
new file mode 100644
index 00000000..1e392e43
--- /dev/null
+++ b/android/src/main/kotlin/so/kontext/sdk/flutter/omsdk/WebViewOMLifecycle.kt
@@ -0,0 +1,154 @@
+package so.kontext.sdk.flutter.omsdk
+
+import android.os.SystemClock
+import android.util.Log
+import android.webkit.WebView
+
+internal class WebViewOMLifecycle(
+ private val webView: WebView,
+ private val initialContentUrl: String?,
+ initialCreativeType: OMCreativeType?,
+ private val canUseOpenMeasurement: () -> Boolean,
+ private val logUnsupportedOpenMeasurement: () -> Unit,
+) {
+ private var activeOMSession: OMSession? = null
+ private var hasDeferredDestroy = false
+ private var hasLoadedPage = false
+ private var hasLoggedUnsupportedOpenMeasurement = false
+ private var lastContentUrl: String? = initialContentUrl
+ private var lastOpenMeasurementFinishTimestampMillis: Long? = null
+ private var omCreativeType: OMCreativeType? = initialCreativeType
+ private var pendingOpenMeasurementStart = false
+
+ init {
+ if (omCreativeType != null && !canUseOpenMeasurement()) {
+ maybeLogUnsupportedOpenMeasurement()
+ omCreativeType = null
+ }
+ }
+
+ fun configure(creativeTypeRaw: String?) {
+ val parsedCreativeType = OMCreativeType.fromRawValue(creativeTypeRaw)
+ if (parsedCreativeType != null && !canUseOpenMeasurement()) {
+ maybeLogUnsupportedOpenMeasurement()
+ omCreativeType = null
+ return
+ }
+
+ omCreativeType = parsedCreativeType
+ startOpenMeasurementSessionIfReady()
+ }
+
+ fun markPageStarted(url: String?) {
+ hasLoadedPage = false
+ lastContentUrl = url ?: initialContentUrl
+ }
+
+ fun markPageFinished(url: String?) {
+ hasLoadedPage = true
+ lastContentUrl = url ?: initialContentUrl
+ startOpenMeasurementSessionIfReady()
+ }
+
+ fun requestStart() {
+ if (!canUseOpenMeasurement()) {
+ if (omCreativeType != null) {
+ maybeLogUnsupportedOpenMeasurement()
+ }
+ return
+ }
+
+ pendingOpenMeasurementStart = true
+ startOpenMeasurementSessionIfReady()
+ }
+
+ fun logError(errorType: String?, message: String?) {
+ activeOMSession?.logError(errorType = errorType, message = message)
+ }
+
+ fun finish() {
+ pendingOpenMeasurementStart = false
+
+ val activeOMSession = activeOMSession ?: return
+ this.activeOMSession = null
+ activeOMSession.retire()
+ activeOMSession.finish()
+ lastOpenMeasurementFinishTimestampMillis = SystemClock.uptimeMillis()
+ }
+
+ fun dispose(): Boolean {
+ if (hasDeferredDestroy) {
+ return true
+ }
+
+ val remainingOpenMeasurementRetentionMillis = remainingOpenMeasurementRetentionMillis()
+ if (remainingOpenMeasurementRetentionMillis <= 0L) {
+ return false
+ }
+
+ hasDeferredDestroy = true
+ OMRetentionPool.retain(
+ webView = OMRetainedWebView(webView),
+ delayMillis = remainingOpenMeasurementRetentionMillis,
+ )
+ return true
+ }
+
+ private fun startOpenMeasurementSessionIfReady() {
+ if (activeOMSession != null || hasDeferredDestroy) {
+ return
+ }
+
+ if (!pendingOpenMeasurementStart) {
+ return
+ }
+
+ val omCreativeType = omCreativeType ?: return
+ if (!hasLoadedPage) {
+ return
+ }
+
+ if (!canUseOpenMeasurement()) {
+ maybeLogUnsupportedOpenMeasurement()
+ pendingOpenMeasurementStart = false
+ return
+ }
+
+ if (!OMManager.activate(webView.context)) {
+ return
+ }
+
+ val session = OMManager.createSession(
+ webView = webView,
+ contentUrl = lastContentUrl ?: webView.url?.toString() ?: initialContentUrl,
+ creativeType = omCreativeType,
+ ) ?: run {
+ pendingOpenMeasurementStart = false
+ return
+ }
+
+ try {
+ session.start()
+ activeOMSession = session
+ lastOpenMeasurementFinishTimestampMillis = null
+ pendingOpenMeasurementStart = false
+ } catch (exception: IllegalStateException) {
+ Log.e(OMConstants.logTag, "OM session start failed", exception)
+ pendingOpenMeasurementStart = false
+ }
+ }
+
+ private fun maybeLogUnsupportedOpenMeasurement() {
+ if (hasLoggedUnsupportedOpenMeasurement) {
+ return
+ }
+ hasLoggedUnsupportedOpenMeasurement = true
+ logUnsupportedOpenMeasurement()
+ }
+
+ private fun remainingOpenMeasurementRetentionMillis(): Long {
+ val finishedAt = lastOpenMeasurementFinishTimestampMillis ?: return 0L
+ val elapsedMillis = SystemClock.uptimeMillis() - finishedAt
+ return (OMConstants.retentionIntervalMillis - elapsedMillis).coerceAtLeast(0L)
+ }
+}
diff --git a/android/src/main/res/raw/omsdk_v1.js b/android/src/main/res/raw/omsdk_v1.js
new file mode 100644
index 00000000..c84aeb27
--- /dev/null
+++ b/android/src/main/res/raw/omsdk_v1.js
@@ -0,0 +1,99 @@
+;(function(omidGlobal) {
+ var n;function aa(a){var b=0;return function(){return bc&&(c=Math.max(c+e,0));c>>0)+'_',e=0;return b});
+u('Symbol.iterator',function(a){if(a)return a;a=Symbol('Symbol.iterator');for(var b='Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array'.split(' '),c=0;cb||1342177279>>=1)c+=c;return d}});
+u('String.prototype.padStart',function(a){return a?a:function(b,c){var d=ma(this,null,'padStart');b-=d.length;c=void 0!==c?String(c):' ';return(0Math.abs(f-c))||(f=b.x,f=f>e||.01>Math.abs(f-e));(e=f)||(e=b.endY,e=eMath.abs(e-d));(d=e)||(b=b.y,d=b>a||.01>Math.abs(b-a));b=!d}return b}function F(a,b){for(var c=!1,d=0;dg&&J.yr){ca=!0;break}}ca&&(f+=Math.round(l)*Math.round(H))}}return c.call(b,0,d-f)};function Ka(){};function La(){}
+function Ma(a,b,c,d,e,f){var h=new Ba;b=new z(b,!1);Ea(h,b);Na(a,b,h,d);if(!e)return h.i=['unmeasurable'],h.s=void 0,h.A=0,h.j=[],h.g&&(a=h.g,c={},a=new z((c.x=0,c.y=0,c.width=a.width,c.height=a.height,c),a.g),h.g=a),h.h=Fa(),h;'locked'===f&&F(h,'deviceLocked');if(b.noOutputDevice)F(h,'backgrounded'),F(h,'noOutputDevice');else if('backgrounded'===c)F(h,'backgrounded');else if(void 0!==h.g){for(a=0;ad.time&&(d=b[e]);c=d;a.m=Rb(c.rootBounds);a.g=Rb(c.boundingClientRect);a.C=Rb(c.intersectionRect);a.M=!!c.isIntersecting;Ib(a)}}catch(f){a.A(),ob(a.L,'generic','Problem handling IntersectionObserver callback: '+f.message)}},{root:null,rootMargin:'0px',threshold:[0,.1,.2,.3,.4,.5,.6,.7,.8,.9,1]})}
+function Qb(a){a.j.ResizeObserver?a.u||(a.u=Sb(a,function(){return Tb(a)}),a.u.observe(a.h)):(a.v||(a.v=function(){return Tb(a)},(0,a.j.addEventListener)('resize',a.v)),a.s||(a.s=new MutationObserver(function(){return Tb(a)}),a.s.observe(a.h,{childList:!1,attributes:!0,subtree:!1})))}function Tb(a){a.h&&!Pb(a.h)&&(Ob(a),Mb(a))}function Sb(a,b){return new a.j.ResizeObserver(b)}function Rb(a){if(a&&null!==a.x&&null!==a.y&&null!==a.width&&null!==a.height)return new z(a,!1)};function Ub(a){if('object'===typeof a&&'object'===typeof a.webOSSystem)return a.webOSSystem}function Vb(a){if('object'===typeof a&&'object'===typeof a.tizen)return a.tizen}function Yb(a){return'object'===typeof Vb(a)};function Zb(a,b){this.h=a;this.g=b};function $b(){return'undefined'!==typeof crypto&&'function'===typeof crypto.getRandomValues}function ac(){var a=new Uint8Array(16);crypto.getRandomValues(a);a[6]=a[6]&15|64;a[8]=a[8]&63|128;for(var b=[],c=0;16>c;c++)b.push(a[c].toString(16).padStart(2,'0'));return b[0]+b[1]+b[2]+b[3]+'-'+b[4]+b[5]+'-'+b[6]+b[7]+'-'+b[8]+b[9]+'-'+b[10]+b[11]+b[12]+b[13]+b[14]+b[15]}
+function bc(){return'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,function(a){var b=16*Math.random()|0;return'y'===a?(b&3|8).toString(16):b.toString(16)})};function cc(a,b){var c=void 0===c?K:c;this.j=a;this.g=c;this.i=b;this.h=[]}
+function dc(a){if(!a.g||!a.g.document)throw Error('OMID Service Script is not running within a window.');var b=a.h;a.h=[];b.forEach(function(c){try{var d=a.i.J?'limited':'full',e=R(c.accessMode,sa)?c.accessMode:null;var f=e?'full'==e&&'limited'==d?d:'domain'==e?'limited':e:d;c.accessMode=f;a:{var h=c.resourceUrl,k=a.g.location.origin;try{var g=new URL(h,k);break a}catch(H){}try{g=new URL(h);break a}catch(H){}g=null}if(d=g){var l=$b()?ac():bc();ec(a,l,d,f);var m=c.vendorKey,r=c.verificationParameters;
+m=void 0===m?'':m;r=void 0===r?'':r;m&&'string'===typeof m&&''!==m&&r&&'string'===typeof r&&''!==r&&(a.j.o[m]=r);a.i.C.set(l,c)}}catch(H){Pa('OMID verification script '+c.resourceUrl+' failed to load: '+H)}})}
+function ec(a,b,c,d){var e=a.g.document,f=e.createElement('iframe');f.id='omid-verification-script-frame-'+b;f.style.display='none';if('full'==d){var h=function(){var k=f.contentWindow;k.omidVerificationProperties={serviceWindow:a.g,injectionSource:'app',injectionId:b};k=k.document;var g=k.createElement('script');g.src=c.href;k.head.appendChild(g);f.removeEventListener('load',h)};f.addEventListener('load',h)}else'limited'==d&&(f.srcdoc="\n