diff --git a/.editorconfig b/.editorconfig index 6dd27866..50a24469 100644 --- a/.editorconfig +++ b/.editorconfig @@ -33,4 +33,7 @@ ktlint_standard_argument-list-wrapping = disabled ktlint_standard_wrapping = disabled # Throw error when class has single class and file name and class name different -ktlint_standard_filename = disabled \ No newline at end of file +ktlint_standard_filename = disabled + +# Composable functions are PascalCase by convention +ktlint_function_naming_ignore_when_annotated_with = Composable \ No newline at end of file diff --git a/build.gradle b/build.gradle index 55faa13a..a49e7619 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { kover(project(':mindbox-huawei-starter')) kover(project(':mindbox-rustore-starter')) kover(project(':mindbox-sdk-starter-core')) + kover(project(':mindbox-embedded-compose')) } tasks.register('clean', Delete) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 256d8e47..5e869fa1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,8 @@ constraint_layout = "2.1.4" threetenapb = "1.4.6" glide = "4.15.1" app_compat = "1.7.1" +compose_bom = "2024.06.00" +compose_compiler = "1.5.8" junit = "4.13.2" androidx_junit = "1.1.3" @@ -75,6 +77,11 @@ androidTest = [ [libraries] kotlin_stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } androidx_core_ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx_core_ktx" } +compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } +compose_ui = { group = "androidx.compose.ui", name = "ui" } +compose_foundation_layout = { group = "androidx.compose.foundation", name = "foundation-layout" } +compose_ui_test_junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose_ui_test_manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx_annotations = { group = "androidx.annotation", name = "annotation", version.ref = "androidx_annotations" } firebase_bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase_bom" } firebase_messaging = { group = "com.google.firebase", name = "firebase-messaging" } @@ -91,6 +98,7 @@ room_ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room_compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } work_manager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work_manager" } androidx_lifecycle = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx_lifecycle" } +androidx_lifecycle_runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "androidx_lifecycle" } androidx_startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx_startup" } hms_push = { group = "com.huawei.hms", name = "push", version.ref = "hms_push" } hms_ads_identifier = { group = "com.huawei.hms", name = "ads-identifier", version.ref = "hms_ads_identifier" } diff --git a/mindbox-embedded-compose/build.gradle b/mindbox-embedded-compose/build.gradle new file mode 100644 index 00000000..0a1ddef7 --- /dev/null +++ b/mindbox-embedded-compose/build.gradle @@ -0,0 +1,39 @@ +apply from: "../modulesCommon.gradle" + +android { + namespace 'cloud.mindbox.mobile_sdk.embedded.compose' + + buildFeatures { + buildConfig = true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.compose.compiler.get() + } + + testOptions { + unitTests.includeAndroidResources = true + } +} + +def useLocalMindboxCommon = providers.gradleProperty("USE_LOCAL_MINDBOX_COMMON").map { it.toBoolean() }.getOrElse(false) + +dependencies { + api project(path: ':sdk') + if (useLocalMindboxCommon) { + compileOnly project(path: ':mindbox-common') + } else { + compileOnly "cloud.mindbox:mindbox-common:$SDK_VERSION_NAME" + } + + implementation platform(libs.compose.bom) + implementation libs.compose.ui + implementation libs.compose.foundation.layout + + // Test dependencies + testImplementation libs.bundles.test + testImplementation platform(libs.compose.bom) + testImplementation libs.compose.ui.test.junit4 + testImplementation libs.compose.ui.test.manifest +} diff --git a/mindbox-embedded-compose/gradle.properties b/mindbox-embedded-compose/gradle.properties new file mode 100644 index 00000000..e851caac --- /dev/null +++ b/mindbox-embedded-compose/gradle.properties @@ -0,0 +1,4 @@ +ARTIFACT_ID=mindbox-embedded-compose +ARTIFACT_NAME=Mindbox Embedded Blocks Compose +MIN_SDK_VERSION=21 +android.disableAutomaticComponentCreation=true diff --git a/mindbox-embedded-compose/proguard-rules.pro b/mindbox-embedded-compose/proguard-rules.pro new file mode 100644 index 00000000..06890b94 --- /dev/null +++ b/mindbox-embedded-compose/proguard-rules.pro @@ -0,0 +1 @@ +# No module-specific rules: the module is a thin Compose wrapper over :sdk. diff --git a/mindbox-embedded-compose/src/main/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlock.kt b/mindbox-embedded-compose/src/main/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlock.kt new file mode 100644 index 00000000..3e261f53 --- /dev/null +++ b/mindbox-embedded-compose/src/main/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlock.kt @@ -0,0 +1,111 @@ +package cloud.mindbox.mobile_sdk.embedded.compose + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView + +/** + * An embedded Mindbox block as a composable. + * + * The caller marks a *place* by its [placeSystemName] — what the place shows is decided by the + * mobile config, the app never learns it. **The caller owns the size**: give the block an + * explicit height (e.g. `Modifier.height(120.dp)`) — the block is a fixed frame and the content + * adapts to it, so the layout never jumps. While the content loads the frame shows a placeholder + * (the SDK's default placeholder or the [placeholder] slot); on failure — the [error] slot, if set. + * + * The behavior mirrors the View one and belongs to the block itself: it is visible while + * loading and showing content, and collapses to zero height when the place ends up without + * content — unless the [error] slot is set: a custom error view is a request to keep the + * place, so the block stays and shows it. The callbacks only report the outcome. + * + * ```kotlin + * MindboxEmbeddedBlock( + * placeSystemName = "main-screen-top", + * modifier = Modifier.height(120.dp), + * onLoad = { /* the block is shown */ }, + * ) + * ``` + * + * @param placeSystemName The place identifier matched against the config's `inlineBlocks` + * section. Changing it recreates the block for the new place. Blocks with the same name work + * independently, each with its own content. + * @param onLoad The block is shown and visible. Main thread. + * @param onFail The place ends up without content — the load failed or timed out, or the + * config had nothing to put here. The block collapsed, or — if the [error] slot is set — + * stayed in place showing it. Not necessarily a breakage: an empty place is a normal outcome. + * Main thread. + * @param placeholder Replaces the SDK's default loading placeholder. Fills the whole block frame. + * @param error The view for a place without content. Setting it also keeps the block visible + * instead of the default collapse. Fills the whole block frame. + */ +@OptIn(InternalMindboxApi::class) +@Composable +public fun MindboxEmbeddedBlock( + placeSystemName: String, + modifier: Modifier = Modifier, + onLoad: () -> Unit = {}, + onFail: () -> Unit = {}, + placeholder: (@Composable () -> Unit)? = null, + error: (@Composable () -> Unit)? = null, +) { + val currentOnLoad by rememberUpdatedState(onLoad) + val currentOnFail by rememberUpdatedState(onFail) + val currentPlaceholder by rememberUpdatedState(placeholder) + val currentError by rememberUpdatedState(error) + + val context = LocalContext.current + + key(placeSystemName) { + var isCollapsed by remember { mutableStateOf(false) } + + val placeholderHost = remember(context) { + lazy(LazyThreadSafetyMode.NONE) { + ComposeView(context).apply { setContent { currentPlaceholder?.invoke() } } + } + } + val errorHost = remember(context) { + lazy(LazyThreadSafetyMode.NONE) { + ComposeView(context).apply { setContent { currentError?.invoke() } } + } + } + + AndroidView( + modifier = (if (isCollapsed) Modifier.height(0.dp).then(modifier) else modifier).fillMaxWidth(), + factory = { viewContext -> + MindboxEmbeddedBlockView(viewContext, placeSystemName).apply { + setVisibilityObserver { isVisible -> isCollapsed = !isVisible } + setListener( + object : MindboxEmbeddedBlockListener { + override fun onLoad(view: MindboxEmbeddedBlockView) { + currentOnLoad() + } + + override fun onFail(view: MindboxEmbeddedBlockView) { + currentOnFail() + } + }, + ) + } + }, + update = { view -> + view.setPlaceholderView(placeholder?.let { placeholderHost.value }) + view.setErrorView(error?.let { errorHost.value }) + }, + onRelease = { view -> view.release() }, + ) + } +} diff --git a/mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlockTest.kt b/mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlockTest.kt new file mode 100644 index 00000000..1b9ea9e0 --- /dev/null +++ b/mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlockTest.kt @@ -0,0 +1,162 @@ +package cloud.mindbox.mobile_sdk.embedded.compose + +import android.os.Looper +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class MindboxEmbeddedBlockTest { + + @get:Rule + val compose = createAndroidComposeRule() + + /** Puts the host window on screen so the block inside starts its content. */ + private fun settle() { + compose.waitForIdle() + dispatchWindowVisibility(compose.activity.window.decorView, View.VISIBLE) + shadowOf(Looper.getMainLooper()).idle() + compose.waitForIdle() + } + + @Test + fun `the block keeps its frame while the config has not arrived`() { + // Nothing is initialized in this test process — the case a host hits when it composes a + // screen before the SDK finished starting up. The block holds the height it was given + // and stays silent: no config yet is not an empty place. + val events = mutableListOf() + + compose.setContent { + MindboxEmbeddedBlock( + placeSystemName = "main-screen-top", + modifier = Modifier + .height(120.dp) + .testTag("block"), + onLoad = { events.add("load") }, + onFail = { events.add("fail") }, + ) + } + settle() + + compose.onNodeWithTag("block").assertExists() + compose.onNodeWithTag("block").assertHeightIsEqualTo(120.dp) + assertTrue(events.isEmpty()) + } + + @Test + fun `a place with no name collapses the block to nothing`() { + // A GONE child cannot shrink a Compose modifier by itself — the wrapper has to collapse + // the frame, or the host layout keeps a hole where the block used to be. + val events = mutableListOf() + + compose.setContent { + MindboxEmbeddedBlock( + placeSystemName = "", + modifier = Modifier + .height(120.dp) + .testTag("block"), + onFail = { events.add("fail") }, + ) + } + settle() + + compose.onNodeWithTag("block").assertHeightIsEqualTo(0.dp) + assertTrue(events.contains("fail")) + } + + @Test + fun `custom slots are accepted and the block still resolves`() { + // Slot rendering itself is covered at the view level (setPlaceholderView/setErrorView); + // here the wiring must simply survive slots being present. + val events = mutableListOf() + + compose.setContent { + MindboxEmbeddedBlock( + placeSystemName = "main-screen-top", + modifier = Modifier.height(120.dp), + onFail = { events.add("fail") }, + placeholder = { Box(Modifier.fillMaxSize().testTag("custom-placeholder")) }, + error = { Box(Modifier.fillMaxSize().testTag("custom-error")) }, + ) + } + settle() + + compose.onNodeWithTag("custom-placeholder").assertExists() + assertTrue(events.isEmpty()) + } + + @Test + fun `a slot that appears after the first composition still reaches the block`() { + // The factory runs once. A caller that decides on its slots later — after a flag loads, + // after a theme resolves — would otherwise never get them installed at all. + val withPlaceholder = mutableStateOf(false) + + compose.setContent { + MindboxEmbeddedBlock( + placeSystemName = "main-screen-top", + modifier = Modifier.height(120.dp), + placeholder = if (withPlaceholder.value) { + { Box(Modifier.fillMaxSize().testTag("custom-placeholder")) } + } else { + null + }, + ) + } + settle() + compose.onNodeWithTag("custom-placeholder").assertDoesNotExist() + + compose.runOnUiThread { withPlaceholder.value = true } + settle() + + compose.onNodeWithTag("custom-placeholder").assertExists() + } + + @Test + fun `default callbacks and slots are optional`() { + compose.setContent { + MindboxEmbeddedBlock( + placeSystemName = "main-screen-top", + modifier = Modifier.height(120.dp), + ) + } + settle() + } + + @Test + fun `leaving the composition frees the block`() { + // AndroidView's onRelease is the only teardown signal a composable gets — without it + // every scroll past the block would leak a WebView. + val shown = mutableStateOf(true) + + compose.setContent { + if (shown.value) { + MindboxEmbeddedBlock( + placeSystemName = "main-screen-top", + modifier = Modifier.height(120.dp).testTag("block"), + ) + } + } + settle() + compose.onNodeWithTag("block").assertExists() + + compose.runOnUiThread { shown.value = false } + settle() + + compose.onNodeWithTag("block").assertDoesNotExist() + } +} diff --git a/mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/WindowVisibility.kt b/mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/WindowVisibility.kt new file mode 100644 index 00000000..d30ed48a --- /dev/null +++ b/mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/WindowVisibility.kt @@ -0,0 +1,21 @@ +package cloud.mindbox.mobile_sdk.embedded.compose + +import android.view.View +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter + +/** + * Robolectric leaves every window at GONE, and the embedded block starts its content only once + * the window is visible — so without this a unit-tested block would never start. On a device the + * framework dispatches the same call down the view tree right after the attach. + * + * The entry point is hidden from the SDK stubs, so it is reached through Robolectric's own + * helper for framework internals. + */ +internal fun dispatchWindowVisibility(view: View, visibility: Int) { + ReflectionHelpers.callInstanceMethod( + view, + "dispatchWindowVisibilityChanged", + ClassParameter.from(Int::class.javaPrimitiveType, visibility), + ) +} diff --git a/sdk/build.gradle b/sdk/build.gradle index 326c6663..212d7f9c 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -15,6 +15,7 @@ dependencies { api("cloud.mindbox:mindbox-huawei-starter:$SDK_VERSION_NAME") api("cloud.mindbox:mindbox-rustore-starter:$SDK_VERSION_NAME") api("cloud.mindbox:mindbox-sdk-starter-core:$SDK_VERSION_NAME") + api("cloud.mindbox:mindbox-embedded-compose:$SDK_VERSION_NAME") } } android { @@ -86,6 +87,7 @@ dependencies { // Handle app lifecycle implementation libs.androidx.lifecycle + implementation libs.androidx.lifecycle.runtime implementation libs.androidx.startup implementation libs.threetenabp diff --git a/sdk/consumer-rules.pro b/sdk/consumer-rules.pro index 80767f1d..fc3665bf 100644 --- a/sdk/consumer-rules.pro +++ b/sdk/consumer-rules.pro @@ -1,3 +1,7 @@ +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + # Keep model classes -keepclassmembers class cloud.mindbox.mobile_sdk.models** { *; } -keep class cloud.mindbox.mobile_sdk.MindboxConfiguration { *; } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt new file mode 100644 index 00000000..ab82fd60 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt @@ -0,0 +1,197 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.os.Handler +import android.os.Looper +import android.view.View +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +internal class EmbeddedBlockContentController( + private val resolveFactory: () -> EmbeddedContentResolution, + private val placeSystemName: String? = null, + private val readyTimeout: Milliseconds = Constants.WebView.readyTimeout, +) { + + var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + + val contentView: View? + get() = provider?.contentView + + private var provider: EmbeddedContentProvider? = null + private var isStarted = false + private var isReleased = false + private var isSessionListenerRegistered = false + private var isTimeoutScheduled = false + private var lastReportedState: EmbeddedBlockState? = null + private var configJob: Job? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + private val timeoutRunnable = Runnable { onReadyTimeout() } + + // The session storage identifies listeners by reference, so add and remove must be given the + // very same instance. + private val onSessionExpired: () -> Unit = { + mainHandler.post { + if (!isReleased) { + mindboxLogI("[EmbeddedBlock] New session, dropping the block content") + dropProvider() + if (isStarted) start() + } + } + } + + // Null until the DI graph is built: a block can be on screen before Mindbox.init returns. + private val sessionStorage: SessionStorageManager? + get() = if (MindboxDI.isInitialized()) MindboxDI.appModule.sessionStorageManager else null + + fun start() { + if (isReleased) return + isStarted = true + registerSessionListener() + if (provider == null) { + placeSystemName?.let { MindboxEventManager.embeddedPlaceRequested(it) } + } + + val current = provider ?: resolveProvider() ?: return + current.onStateChange = { state -> report(state) } + scheduleTimeout() + current.start() + } + + fun pause() { + isStarted = false + cancelTimeout() + provider?.pause() + } + + fun release() { + isStarted = false + isReleased = true + cancelTimeout() + unregisterSessionListener() + stopWaitingForConfig() + dropProvider() + } + + private fun report(state: EmbeddedBlockState) { + // Any answer from the page settles the budget, including one the container already knows. + if (state !is EmbeddedBlockState.Loading) cancelTimeout() + // A page reports its height on every relayout, and each of those arrives as another + // Ready. Repeating a state the container is already in is pure churn. + if (state == lastReportedState) return + lastReportedState = state + onStateChange?.invoke(state) + } + + private fun scheduleTimeout() { + if (isTimeoutScheduled) return + isTimeoutScheduled = true + mainHandler.postDelayed(timeoutRunnable, readyTimeout.interval) + } + + private fun cancelTimeout() { + if (!isTimeoutScheduled) return + isTimeoutScheduled = false + mainHandler.removeCallbacks(timeoutRunnable) + } + + private fun onReadyTimeout() { + isTimeoutScheduled = false + if (!isStarted || provider == null) return + mindboxLogW( + "[EmbeddedBlock] Page for '$placeSystemName' stayed silent for " + + "${readyTimeout.interval}ms after load, reporting failure", + ) + pause() + unregisterSessionListener() + report(EmbeddedBlockState.Failed) + } + + private fun dropProvider() { + cancelTimeout() + provider?.release() + provider = null + } + + private fun resolveProvider(): EmbeddedContentProvider? { + val resolution = runCatching { resolveFactory() }.getOrElse { error -> + mindboxLogE("[EmbeddedBlock] Content resolution failed, the block reports failure", error) + report(EmbeddedBlockState.Failed) + return null + } + return when (resolution) { + is EmbeddedContentResolution.Content -> { + stopWaitingForConfig() + provider = resolution.provider + resolution.provider + } + is EmbeddedContentResolution.NothingToShow -> { + stopWaitingForConfig() + report(EmbeddedBlockState.Empty) + null + } + is EmbeddedContentResolution.NotReadyYet -> { + waitForConfig() + null + } + } + } + + private fun waitForConfig() { + report(EmbeddedBlockState.Loading) + if (configJob != null) return + configJob = loggingRunCatching(defaultValue = null) { + MindboxPreferences.inAppConfigFlow + .onEach { mainHandler.post { onConfigArrived() } } + .launchIn(Mindbox.mindboxScope) + } + } + + private fun onConfigArrived() { + if (isReleased || provider != null) return + mindboxLogI("[EmbeddedBlock] Config arrived, resolving '$placeSystemName' again") + // The graph is up by now — a listener the block could not take before it can take here. + registerSessionListener() + val resolved = resolveProvider() ?: return + resolved.onStateChange = { state -> report(state) } + if (isStarted) { + scheduleTimeout() + resolved.start() + } else { + resolved.pause() + } + } + + private fun stopWaitingForConfig() { + val job = configJob ?: return + configJob = null + loggingRunCatching { job.cancel() } + } + + private fun registerSessionListener() { + if (isSessionListenerRegistered) return + isSessionListenerRegistered = loggingRunCatching(defaultValue = false) { + val storage = sessionStorage ?: return@loggingRunCatching false + storage.addSessionExpirationListener(onSessionExpired) + true + } + } + + private fun unregisterSessionListener() { + if (!isSessionListenerRegistered) return + isSessionListenerRegistered = false + loggingRunCatching { sessionStorage?.removeSessionExpirationListener(onSessionExpired) } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt new file mode 100644 index 00000000..329907d9 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt @@ -0,0 +1,71 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.content.Context +import androidx.annotation.MainThread +import cloud.mindbox.mobile_sdk.embedded.webview.TempEmbeddedBlockPageContract +import cloud.mindbox.mobile_sdk.embedded.webview.EmbeddedBlockWebViewPage +import cloud.mindbox.mobile_sdk.embedded.webview.EmbeddedBlockWebViewProvider +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.embedded.mock.TempMindboxStoriesFeedMock +import cloud.mindbox.mobile_sdk.embedded.mock.TempStoriesFeedMockPage + +internal class EmbeddedBlockContentFactory( + private val rawConfig: () -> String = { MindboxPreferences.inAppConfig }, +) { + + fun create(context: Context, placeSystemName: String): EmbeddedContentResolution { + val rawConfig = rawConfig() + if (rawConfig.isBlank()) { + mindboxLogI("[EmbeddedBlock] Config is not loaded yet, the block keeps waiting") + return EmbeddedContentResolution.NotReadyYet + } + val config = TempEmbeddedBlocksConfig.parse(rawConfig) ?: run { + mindboxLogI("[EmbeddedBlock] Config has no inlineBlocks section, the block stays collapsed") + return EmbeddedContentResolution.NothingToShow + } + val candidates = config.placementsFor(placeSystemName) + if (candidates.isEmpty()) { + mindboxLogI( + "[EmbeddedBlock] Config has no placement '$placeSystemName', the block stays collapsed", + ) + return EmbeddedContentResolution.NothingToShow + } + // TODO(MOBILE-324): the backend sends every candidate for the place and the SDK picks the + // one whose targeting matches; with the in-app migration that check is the in-app + // targeting engine — until then the first candidate wins. + return EmbeddedContentResolution.Content(createPageContent(context, candidates.first())) + } + + private fun createPageContent( + context: Context, + placement: TempEmbeddedBlockPlacement, + ): EmbeddedContentProvider { + val pageUrl = placement.pageUrl + val page = EmbeddedBlockWebViewPage( + source = pageUrl + ?.let { EmbeddedBlockWebViewPage.Source.Url(it) } + ?: EmbeddedBlockWebViewPage.Source.Html( + TempStoriesFeedMockPage.html(TempMindboxStoriesFeedMock.scenario), + ), + context = context, + bridgeName = TempEmbeddedBlockPageContract.BRIDGE_NAME, + // The mock reports over the bridge and never sets the DOM flag; polling it would spin + // for as long as the block is shown. + domReadyFlag = pageUrl?.let { TempEmbeddedBlockPageContract.DOM_READY_FLAG }, + ) + return EmbeddedBlockWebViewProvider(page) + } + + internal companion object { + + @MainThread + fun resolve(context: Context, placeSystemName: String?): EmbeddedContentResolution = when { + placeSystemName.isNullOrBlank() -> { + mindboxLogI("[EmbeddedBlock] No placeSystemName, nothing to resolve") + EmbeddedContentResolution.NothingToShow + } + else -> EmbeddedBlockContentFactory().create(context, placeSystemName) + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockDefaultViews.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockDefaultViews.kt new file mode 100644 index 00000000..d9aa16e6 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockDefaultViews.kt @@ -0,0 +1,64 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.view.View +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.widget.FrameLayout +import androidx.core.content.ContextCompat +import cloud.mindbox.mobile_sdk.R +import cloud.mindbox.mobile_sdk.px + +internal object EmbeddedBlockDefaultViews { + + fun placeholder(context: Context): View = PulsingPlaceholderView(context) + + private fun roundedRect(color: Int): GradientDrawable = + GradientDrawable().apply { + cornerRadius = CORNER_RADIUS_DP.px.toFloat() + setColor(color) + } + + private class PulsingPlaceholderView(context: Context) : FrameLayout(context) { + + // Resolved from resources, so values-night handles the dark screen: a light rectangle + // there reads as a lit block, not as content on its way. + private val fill = View(context).apply { + background = roundedRect( + ContextCompat.getColor(context, R.color.mindbox_embedded_block_placeholder), + ) + } + + init { + addView( + fill, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).apply { + INSET_DP.px.let { setMargins(it, it, it, it) } + }, + ) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + fill.startAnimation( + AlphaAnimation(1f, PULSE_MIN_ALPHA).apply { + duration = PULSE_DURATION_MS + repeatMode = Animation.REVERSE + repeatCount = Animation.INFINITE + }, + ) + } + + override fun onDetachedFromWindow() { + fill.clearAnimation() + super.onDetachedFromWindow() + } + } + + private const val CORNER_RADIUS_DP = 8.0 + private const val INSET_DP = 6 + + private const val PULSE_MIN_ALPHA = 0.45f + private const val PULSE_DURATION_MS = 700L +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockState.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockState.kt new file mode 100644 index 00000000..e9d7e8f1 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockState.kt @@ -0,0 +1,15 @@ +package cloud.mindbox.mobile_sdk.embedded + +internal sealed class EmbeddedBlockState { + + data object Loading : EmbeddedBlockState() + + data object Ready : EmbeddedBlockState() + + data object Empty : EmbeddedBlockState() + + data object Failed : EmbeddedBlockState() + + val nothingToShow: Boolean + get() = this is Empty || this is Failed +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentProvider.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentProvider.kt new file mode 100644 index 00000000..9c211171 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentProvider.kt @@ -0,0 +1,16 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.view.View + +internal interface EmbeddedContentProvider { + + var onStateChange: ((EmbeddedBlockState) -> Unit)? + + val contentView: View? + + fun start() + + fun pause() + + fun release() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentResolution.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentResolution.kt new file mode 100644 index 00000000..97287fa2 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentResolution.kt @@ -0,0 +1,10 @@ +package cloud.mindbox.mobile_sdk.embedded + +internal sealed class EmbeddedContentResolution { + + data class Content(val provider: EmbeddedContentProvider) : EmbeddedContentResolution() + + data object NothingToShow : EmbeddedContentResolution() + + data object NotReadyYet : EmbeddedContentResolution() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt new file mode 100644 index 00000000..84c4288a --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt @@ -0,0 +1,44 @@ +package cloud.mindbox.mobile_sdk.embedded + +/** + * Reports the outcome of a [MindboxEmbeddedBlockView]: the block either shows content, or the + * place stays without it. + * + * The listener only observes — the block applies its own show/hide behavior before the callback + * and works the same with no listener at all. Register with + * [MindboxEmbeddedBlockView.setListener]; both methods are optional, override only what you need. + * From Java, extend [MindboxEmbeddedBlockListenerAdapter] to get the same freedom. + * + * Callbacks arrive on the main thread, each outcome once. A listener registered after the block + * already loaded or failed still gets the current outcome. Registering the same listener again + * changes nothing — the outcome is not replayed to someone who already heard it. + */ +public interface MindboxEmbeddedBlockListener { + + /** + * The content loaded and is visible inside the block. + * + * @param view The block that loaded — tell several blocks apart by + * [MindboxEmbeddedBlockView.placeSystemName]. + */ + public fun onLoad(view: MindboxEmbeddedBlockView) {} + + /** + * The place stays without content — the load failed or timed out, or the config had nothing + * to put here. An empty place is a normal outcome, not a breakage. + * + * The block already hid itself, unless [MindboxEmbeddedBlockView.setErrorView] is set — then + * it keeps its place and shows that view. Nothing is required here: the block retries by + * itself, and how depends on why the place stayed empty. + * + * - The config had no placement for this place — resolved again every time the block comes + * back on screen, and on a new session. + * - The page failed to load — reloaded on a new session. Coming back on screen replays the + * same outcome instead: the page is already there and it is broken. + * - The page stayed silent past its timeout — given another attempt when the block comes back + * on screen. A new session reloads it too, but only after that attempt. + * + * @param view The block left without content. + */ + public fun onFail(view: MindboxEmbeddedBlockView) {} +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListenerAdapter.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListenerAdapter.kt new file mode 100644 index 00000000..937982b7 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListenerAdapter.kt @@ -0,0 +1,25 @@ +package cloud.mindbox.mobile_sdk.embedded + +/** + * A [MindboxEmbeddedBlockListener] with both callbacks already implemented as no-ops — extend it + * and override only the ones you need. + * + * **For Java hosts.** Kotlin classes can implement [MindboxEmbeddedBlockListener] directly and + * still override one callback out of two; Java sees the interface methods as abstract and would + * have to implement both, so this class exists to spare it the empty method. + * + * ```java + * blockView.setListener(new MindboxEmbeddedBlockListenerAdapter() { + * @Override + * public void onLoad(MindboxEmbeddedBlockView view) { + * // the block is shown + * } + * }); + * ``` + */ +public abstract class MindboxEmbeddedBlockListenerAdapter : MindboxEmbeddedBlockListener { + + override fun onLoad(view: MindboxEmbeddedBlockView) {} + + override fun onFail(view: MindboxEmbeddedBlockView) {} +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt new file mode 100644 index 00000000..7e5cad38 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt @@ -0,0 +1,340 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.content.Context +import android.graphics.Color +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import cloud.mindbox.mobile_sdk.R +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import kotlin.math.abs + +/** + * A drop-in container for an embedded Mindbox block. + * + * The host marks a *place* by [placeSystemName] and never learns what goes into it — the mobile + * config decides through its `inlineBlocks` section and can change it without an app release. + * Blocks sharing a place work independently. + * + * **The host owns the size**: give the block an explicit height. The content adapts to that + * frame, so the host UI never jumps. While loading, the frame shows a placeholder — the SDK's + * default one or the host's own ([setPlaceholderView]). When the place ends up without content the + * block hides itself, unless the host gave it a view to show instead ([setErrorView]). + * + * ```xml + * + * ``` + * + * The SDK owns the flow: content starts on attach, pauses on detach, reloads once per session. + * The block owns its behavior too — visible while loading and while showing content, `GONE` + * when the place ends up without content, unless [setErrorView] keeps it in place. + * [setListener] only observes: callbacks arrive after the block already acted. + */ +public class MindboxEmbeddedBlockView internal constructor( + context: Context, + attrs: AttributeSet?, + placeSystemName: String?, + private val contentController: EmbeddedBlockContentController = EmbeddedBlockContentController( + resolveFactory = { EmbeddedBlockContentFactory.resolve(context, placeSystemName.orNullIfBlank()) }, + placeSystemName = placeSystemName.orNullIfBlank(), + ), +) : FrameLayout(context, attrs) { + + @JvmOverloads + public constructor( + context: Context, + attrs: AttributeSet? = null, + ) : this(context, attrs, readPlaceSystemName(context, attrs)) + + public constructor( + context: Context, + placeSystemName: String, + ) : this(context, null, placeSystemName) + + public val placeSystemName: String? = placeSystemName.orNullIfBlank() + private var listener: MindboxEmbeddedBlockListener = DefaultListener + private var visibilityObserver: ((Boolean) -> Unit)? = null + private var placeholderView: View? = null + private var errorView: View? = null + private val defaultPlaceholder by lazy { EmbeddedBlockDefaultViews.placeholder(context) } + private val mainHandler = Handler(Looper.getMainLooper()) + + private enum class BlockEvent { LOADING, LOADED, FAILED } + + private var state: EmbeddedBlockState = EmbeddedBlockState.Loading + set(value) { + field = value + applyState(value) + } + + private var deliveredEvent: BlockEvent? = null + private var isDeliveryScheduled = false + private var shownContent: View? = null + private var isContentStarted = false + private var observedLifecycle: Lifecycle? = null + + private val hostDestroyObserver = object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + mindboxLogI("[EmbeddedBlock] Host screen destroyed, freeing content") + detachFromHost() + loggingRunCatching { contentController.release() } + } + } + + init { + clipChildren = true + clipToPadding = true + setBackgroundColor(Color.TRANSPARENT) + contentController.onStateChange = { newState -> state = newState } + showContent(currentPlaceholder()) + warnIfPlaceIsMissing() + } + + private fun warnIfPlaceIsMissing() { + if (placeSystemName != null) return + mindboxLogE( + "[EmbeddedBlock] app:mindboxPlaceSystemName is not set on the block: it has nothing " + + "to resolve and stays hidden. Set the attribute in XML, or create the block as " + + "MindboxEmbeddedBlockView(context, placeSystemName).", + ) + } + + public fun setListener(listener: MindboxEmbeddedBlockListener?) { + val next = listener ?: DefaultListener + // The same listener is not a new subscriber. A host rebinds it on every recycled row, and + // replaying an outcome it already heard would have it rebuild its layout again — which + // rebinds the listener again. + if (next === this.listener) return + + this.listener = next + if (listener == null) return + // A new subscriber has heard nothing yet, so the current outcome is still news to it. + deliveredEvent = null + scheduleDelivery() + } + + /** + * Replaces the SDK's default loading placeholder. Fills the whole block frame. + * + * Takes effect immediately: a block that is loading right now swaps to the new placeholder. + * Pass `null` to go back to the default one. + */ + public fun setPlaceholderView(view: View?) { + placeholderView = view + if (state is EmbeddedBlockState.Loading) showContent(currentPlaceholder()) + } + + /** + * The view for a place that ended up without content. Setting it also keeps the block + * visible instead of the default collapse. Fills the whole block frame. + * + * Applies from the next outcome on: a block that already collapsed stays collapsed until + * its content reloads. + */ + public fun setErrorView(view: View?) { + errorView = view + } + + @InternalMindboxApi + public fun setVisibilityObserver(observer: ((isVisible: Boolean) -> Unit)?) { + visibilityObserver = observer + } + + private val hasCustomErrorView: Boolean + get() = errorView != null + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + observeHostDestruction() + if (windowVisibility == VISIBLE) startContent() + } + + override fun onDetachedFromWindow() { + pauseContent() + super.onDetachedFromWindow() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + if (visibility == VISIBLE) startContent() else pauseContent() + } + + private fun startContent() { + if (isContentStarted) return + isContentStarted = true + mindboxLogI("[EmbeddedBlock] On screen (place='$placeSystemName'), starting content") + loggingRunCatching { contentController.start() } + } + + private fun pauseContent() { + if (!isContentStarted) return + isContentStarted = false + mindboxLogI("[EmbeddedBlock] Off screen, pausing content") + loggingRunCatching { contentController.pause() } + } + + private fun observeHostDestruction(): Unit = loggingRunCatching { + val lifecycle = findViewTreeLifecycleOwner()?.lifecycle ?: return@loggingRunCatching + if (lifecycle === observedLifecycle) return@loggingRunCatching + observedLifecycle?.removeObserver(hostDestroyObserver) + observedLifecycle = lifecycle + lifecycle.addObserver(hostDestroyObserver) + } + + @InternalMindboxApi + public fun release() { + mindboxLogI("[EmbeddedBlock] Released by the host wrapper, freeing content") + detachFromHost() + loggingRunCatching { contentController.release() } + } + + private fun detachFromHost(): Unit = loggingRunCatching { + observedLifecycle?.removeObserver(hostDestroyObserver) + observedLifecycle = null + mainHandler.removeCallbacksAndMessages(null) + isDeliveryScheduled = false + listener = DefaultListener + } + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var touchDownX = 0f + private var touchDownY = 0f + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + touchDownX = ev.x + touchDownY = ev.y + if (state is EmbeddedBlockState.Ready) { + parent?.requestDisallowInterceptTouchEvent(true) + } + } + MotionEvent.ACTION_MOVE -> { + val dx = abs(ev.x - touchDownX) + val dy = abs(ev.y - touchDownY) + if (dy > touchSlop && dy > dx) { + parent?.requestDisallowInterceptTouchEvent(false) + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + parent?.requestDisallowInterceptTouchEvent(false) + } + } + return super.dispatchTouchEvent(ev) + } + + private fun applyState(state: EmbeddedBlockState) { + when (state) { + is EmbeddedBlockState.Ready -> { + val readyContent = contentController.contentView + if (readyContent == null) { + mindboxLogW("[EmbeddedBlock] Ready content has no view, treating it as a failure") + this.state = EmbeddedBlockState.Failed + return + } + mindboxLogI("[EmbeddedBlock] Content ready") + showContent(readyContent) + } + is EmbeddedBlockState.Loading -> { + mindboxLogI("[EmbeddedBlock] Content loading, showing the placeholder") + showContent(currentPlaceholder()) + } + + is EmbeddedBlockState.Empty -> { + mindboxLogI("[EmbeddedBlock] Nothing to show for this place") + showErrorView() + } + is EmbeddedBlockState.Failed -> { + mindboxLogI("[EmbeddedBlock] Content failed, showing the error state") + showErrorView() + } + } + + applyDefaultVisibility(state) + scheduleDelivery() + } + + private fun applyDefaultVisibility(state: EmbeddedBlockState) { + val isVisible = when { + state.nothingToShow -> hasCustomErrorView + else -> true + } + visibility = if (isVisible) VISIBLE else GONE + loggingRunCatching { visibilityObserver?.invoke(isVisible) } + } + + private fun currentPlaceholder(): View = placeholderView ?: defaultPlaceholder + + private fun showErrorView() { + val view = errorView + if (view == null) clearContent() else showContent(view) + } + + private fun showContent(content: View): Unit = loggingRunCatching { + if (content === shownContent) return@loggingRunCatching + shownContent?.let { removeView(it) } + shownContent = content + (content.parent as? ViewGroup)?.removeView(content) + addView(content, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + private fun clearContent() { + shownContent?.let { removeView(it) } + shownContent = null + } + + private fun scheduleDelivery() { + if (isDeliveryScheduled) return + isDeliveryScheduled = true + mainHandler.post { deliverPendingEvent() } + } + + private fun deliverPendingEvent(): Unit = loggingRunCatching { + isDeliveryScheduled = false + val event = when { + state is EmbeddedBlockState.Ready -> BlockEvent.LOADED + state.nothingToShow -> BlockEvent.FAILED + else -> BlockEvent.LOADING + } + if (event == deliveredEvent) return@loggingRunCatching + + deliveredEvent = event + when (event) { + BlockEvent.LOADED -> listener.onLoad(this) + BlockEvent.FAILED -> listener.onFail(this) + BlockEvent.LOADING -> Unit + } + } + + private companion object { + private val DefaultListener = object : MindboxEmbeddedBlockListener {} + } +} + +private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() } + +private fun readPlaceSystemName(context: Context, attrs: AttributeSet?): String? { + if (attrs == null) return null + val values = context.obtainStyledAttributes(attrs, R.styleable.MindboxEmbeddedBlockView) + return try { + values.getString(R.styleable.MindboxEmbeddedBlockView_mindboxPlaceSystemName) + } finally { + values.recycle() + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/TempEmbeddedBlocksConfig.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/TempEmbeddedBlocksConfig.kt new file mode 100644 index 00000000..3d2850eb --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/TempEmbeddedBlocksConfig.kt @@ -0,0 +1,60 @@ +package cloud.mindbox.mobile_sdk.embedded + +import cloud.mindbox.mobile_sdk.embedded.mock.TempEmbeddedBlockUsage +import org.json.JSONArray +import org.json.JSONObject + +internal data class TempEmbeddedBlockPlacement( + val placeSystemName: String, + val pageUrl: String?, +) + +internal data class TempEmbeddedBlocksConfig( + val placements: List, +) { + + fun placementsFor(placeSystemName: String): List = + placements.filter { it.placeSystemName == placeSystemName } + + companion object { + + internal const val SECTION_KEY = "inlineBlocks" + internal const val KEY_PLACE_SYSTEM_NAME = "placeSystemName" + internal const val KEY_PAGE_URL = "pageUrl" + + // Parsing walks the whole raw config on the main thread at every attach; the same string + // instance is handed out by SharedPreferences until the config actually updates. + @Volatile + private var cache: Pair? = null + + fun parse(rawInAppConfig: String): TempEmbeddedBlocksConfig? { + TempEmbeddedBlockUsage.report("temporary inlineBlocks config parser (MOBILE-344 replaces it)") + cache?.let { (raw, parsed) -> if (raw === rawInAppConfig) return parsed } + val parsed = parseUncached(rawInAppConfig) + cache = rawInAppConfig to parsed + return parsed + } + + private fun parseUncached(rawInAppConfig: String): TempEmbeddedBlocksConfig? { + val root = runCatching { JSONObject(rawInAppConfig) }.getOrNull() ?: return null + val section = root.optJSONArray(SECTION_KEY) ?: return null + return TempEmbeddedBlocksConfig(placements = section.parsePlacements()) + } + + private fun JSONArray.parsePlacements(): List = + (0 until length()).mapNotNull { index -> + val entry = optJSONObject(index) ?: return@mapNotNull null + val placeSystemName = entry.optString(KEY_PLACE_SYSTEM_NAME) + .takeIf { it.isNotBlank() } ?: return@mapNotNull null + val pageUrl = if (entry.isNull(KEY_PAGE_URL)) { + null + } else { + entry.optString(KEY_PAGE_URL).takeIf { it.isNotBlank() } + } + TempEmbeddedBlockPlacement( + placeSystemName = placeSystemName, + pageUrl = pageUrl, + ) + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlockUsage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlockUsage.kt new file mode 100644 index 00000000..2b295526 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlockUsage.kt @@ -0,0 +1,25 @@ +package cloud.mindbox.mobile_sdk.embedded.mock + +import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import java.util.concurrent.ConcurrentHashMap + +// MUST NOT REACH `develop`: every temporary and mock piece of the embedded block announces itself +// the first time it is used. One run of the app therefore lists what is still wired in, and a run +// with no such line left is the proof that the sweep after the real contract is complete. +// +// Grep anchor for that sweep: "Used mock! Need delete". +internal object TempEmbeddedBlockUsage { + + // Once per site per process. These sit on paths that run per config fetch, per attach and per + // page message — a line on every call would bury the very log it is meant to draw attention to. + private val reported = ConcurrentHashMap() + + // Some sites report from a class initializer, where a throwing logger would turn into an + // ExceptionInInitializerError and take the feature down. A marker must never be able to do + // that: it is bookkeeping, not behavior. + fun report(site: String) { + if (reported.putIfAbsent(site, Unit) == null) { + runCatching { mindboxLogE("Used mock! Need delete — $site") } + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlocksMockConfigSection.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlocksMockConfigSection.kt new file mode 100644 index 00000000..5d19625d --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlocksMockConfigSection.kt @@ -0,0 +1,41 @@ +package cloud.mindbox.mobile_sdk.embedded.mock + +import cloud.mindbox.mobile_sdk.BuildConfig +import cloud.mindbox.mobile_sdk.embedded.TempEmbeddedBlocksConfig +import org.json.JSONArray +import org.json.JSONObject + +// MUST NOT REACH `develop`: injects a hardcoded `inlineBlocks` section into the fetched mobile +// config until the backend sends one. Delete the injection call when the contract lands. +internal object TempEmbeddedBlocksMockConfigSection { + + const val PLACE_MAIN = "main-screen-top" + const val PLACE_SECONDARY = "main-screen-bottom" + + const val STORIES_STAGING_PAGE_URL = + "https://mobile-static-staging.mindbox.ru/inapps/webview/content/stories.html" + + fun inject(rawConfig: String): String = runCatching { + // A release build of the SDK must never inject the staging URL into a host app's config. + if (!BuildConfig.DEBUG) return@runCatching rawConfig + val root = JSONObject(rawConfig) + if (root.has(TempEmbeddedBlocksConfig.SECTION_KEY)) return@runCatching rawConfig + + TempEmbeddedBlockUsage.report("mock inlineBlocks section injected into the mobile config") + val placements = JSONArray() + // The secondary place stays on the mock page, so the harness scenario switch + // (SUCCESS/EMPTY/ERROR/SLOW) keeps a place to drive. + .put(mockPlacement(PLACE_MAIN, STORIES_STAGING_PAGE_URL)) + .put(mockPlacement(PLACE_SECONDARY, pageUrl = null)) + root.put(TempEmbeddedBlocksConfig.SECTION_KEY, placements) + // Android's JSONObject.toString() returns null instead of throwing — that null must never + // erase the config. + val serialized: String? = root.toString() + serialized ?: rawConfig + }.getOrDefault(rawConfig) + + private fun mockPlacement(placeSystemName: String, pageUrl: String?): JSONObject = + JSONObject() + .put(TempEmbeddedBlocksConfig.KEY_PLACE_SYSTEM_NAME, placeSystemName) + .put(TempEmbeddedBlocksConfig.KEY_PAGE_URL, pageUrl ?: JSONObject.NULL) +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempMindboxStoriesFeedMock.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempMindboxStoriesFeedMock.kt new file mode 100644 index 00000000..3927b3bb --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempMindboxStoriesFeedMock.kt @@ -0,0 +1,39 @@ +package cloud.mindbox.mobile_sdk.embedded.mock + +/** + * Debug switch for the temporary mock feed page. Lets the test app drive the mock through the + * scenarios a real page can end up in. Goes away together with the mock page once the config + * hands out a real feed URL. + * + * The scenario is baked into the page HTML at build time, and every block builds its own page — + * blocks created after the switch use the new scenario, blocks already on screen keep theirs + * until their content reloads (re-creation or a new session). + * + * Not annotated with `InternalMindboxApi`: the marker lives in mindbox-common, which host apps + * do not see, and this object exists precisely for the test app. + */ +public object TempMindboxStoriesFeedMock { + + public enum class Scenario { + + /** The feed renders and reports its height — the happy path. */ + SUCCESS, + + /** Targeting matched nothing: the page says so outright — the empty state (hidden by default). */ + EMPTY, + + /** The page never answers: the container times out into the error state. */ + ERROR, + + /** The page answers, but later than the container's timeout — same as ERROR for the host. */ + SLOW, + } + + // On the initializer, not on the setter: the test app reads the current scenario to draw its + // menu, and a read is just as much proof that the switch is still wired in as a write. + init { + TempEmbeddedBlockUsage.report("mock scenario switch is wired into the host app") + } + + public var scenario: Scenario = Scenario.SUCCESS +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempStoriesFeedMockPage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempStoriesFeedMockPage.kt new file mode 100644 index 00000000..a9c49b02 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempStoriesFeedMockPage.kt @@ -0,0 +1,218 @@ +package cloud.mindbox.mobile_sdk.embedded.mock + +// MUST NOT REACH `develop`: stands in for the real page URL. Ported from the iOS mock so the +// page contract stays identical across platforms. +internal object TempStoriesFeedMockPage { + + fun html(scenario: TempMindboxStoriesFeedMock.Scenario): String { + TempEmbeddedBlockUsage.report("mock stories feed page served instead of a real page") + return PAGE_TEMPLATE.replace("__SCENARIO__", scenario.name) + } + + private val PAGE_TEMPLATE = """ + + + + + + + +
+
MOCK
+ + + + """.trimIndent() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockPage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockPage.kt new file mode 100644 index 00000000..0dd18281 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockPage.kt @@ -0,0 +1,23 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import android.view.View +import org.json.JSONObject + +internal interface EmbeddedBlockPage { + + val view: View + + var onMessage: ((TempEmbeddedBlockPageMessage) -> Unit)? + + var onMechanicMessage: ((payload: JSONObject) -> Unit)? + + var onPageError: ((description: String) -> Unit)? + + fun load() + + fun pause() + + fun resume() + + fun release() +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPage.kt new file mode 100644 index 00000000..499b3832 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPage.kt @@ -0,0 +1,319 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.View +import android.view.ViewGroup +import android.webkit.JavascriptInterface +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting +import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.models.Timestamp +import org.json.JSONObject +import java.lang.ref.WeakReference + +internal class EmbeddedBlockWebViewPage( + private val source: Source, + context: Context, + private val bridgeName: String, + private val domReadyFlag: String? = null, +) : EmbeddedBlockPage { + + internal sealed class Source { + data class Html(val html: String) : Source() + + data class Url(val url: String) : Source() + } + + private val webView: WebView = WebView(context) + + override val view: View + get() = webView + + override var onMessage: ((TempEmbeddedBlockPageMessage) -> Unit)? = null + + override var onMechanicMessage: ((payload: JSONObject) -> Unit)? = null + + override var onPageError: ((description: String) -> Unit)? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + private var isBridgeAdded = false + + // removeJavascriptInterface does not take effect for an already-loaded page, so silence after + // pause() is enforced on the native side of the bridge as well. + private var isConnected = false + + // The renderer died: the page may never be driven again, but the WebView object itself still + // must be released. + private var isRendererGone = false + private var isReleased = false + + private val isDead: Boolean + get() = isRendererGone || isReleased + + private var isPageResolved = false + + init { + setUpWebView() + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setUpWebView() { + webView.settings.javaScriptEnabled = true + + webView.settings.allowFileAccess = false + webView.settings.allowContentAccess = false + + // Explicit: the mixed-content default depends on the HOST app's targetSdk. + webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + webView.settings.setGeolocationEnabled(false) + + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + // Only the main frame: dynamic iframes legitimately start at about:blank. + if (!request.isForMainFrame) return false + mindboxLogW("[EmbeddedBlock] Blocked navigation from the block page: ${request.url.toString().take(URL_LOG_LIMIT)}") + return true + } + + // API 21-23 call this one instead, and its default allows the navigation. + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + mindboxLogW("[EmbeddedBlock] Blocked navigation from the block page: ${url.take(URL_LOG_LIMIT)}") + return true + } + + @RequiresApi(Build.VERSION_CODES.M) + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!request.isForMainFrame) return + reportPageError("Page load error ${error.errorCode}: ${error.description}") + } + + @Deprecated("Deprecated in Java") + override fun onReceivedError( + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String?, + ) { + reportPageError("Page load error $errorCode: $description") + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (!request.isForMainFrame) return + reportPageError("Page HTTP error ${errorResponse.statusCode}") + } + + // Without this override a dead renderer CRASHES THE HOST APP (API 26+ default). + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + isRendererGone = true + (view.parent as? ViewGroup)?.removeView(view) + reportPageError("WebView renderer process is gone") + return true + } + + override fun onPageFinished(view: WebView, url: String?) { + startDomReadyPoll() + } + } + + webView.setBackgroundColor(Color.TRANSPARENT) + + webView.isVerticalScrollBarEnabled = false + webView.isHorizontalScrollBarEnabled = false + webView.overScrollMode = View.OVER_SCROLL_NEVER + } + + override fun load() { + if (isDead) return + addBridgeIfNeeded() + isConnected = true + isPageResolved = false + webView.onResume() + + when (source) { + is Source.Html -> webView.loadDataWithBaseURL(null, source.html, "text/html", "utf-8", null) + is Source.Url -> { + if (!isHttpsPageUrl(source.url)) { + mindboxLogE("[EmbeddedBlock] Refusing to load non-https block page: ${source.url.take(URL_LOG_LIMIT)}") + return + } + webView.loadUrl(source.url) + } + } + } + + override fun pause() { + isConnected = false + if (isDead) return + webView.onPause() + } + + override fun resume() { + if (isDead) return + isConnected = true + webView.onResume() + if (!isPageResolved) startDomReadyPoll() + } + + // Parsed, not prefix-matched: https://mobile-static.mindbox.ru@evil.example looks legitimate + // and loads evil.example. + private fun isHttpsPageUrl(url: String): Boolean { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false + return "https".equals(uri.scheme, ignoreCase = true) && uri.userInfo.isNullOrEmpty() + } + + override fun release() { + isConnected = false + if (isReleased) return + isReleased = true + onMessage = null + onMechanicMessage = null + onPageError = null + // A crashed WebView still must be destroyed, only its page must not be driven; and a + // parented WebView must not be destroyed at all, hence the detach. + if (!isRendererGone) webView.stopLoading() + (webView.parent as? ViewGroup)?.removeView(webView) + webView.destroy() + } + + private fun reportPageError(description: String) { + isPageResolved = true + mindboxLogW("[EmbeddedBlock] $description") + onPageError?.invoke(description) + } + + private fun startDomReadyPoll() { + val flag = domReadyFlag ?: return + if (isPageResolved) return + val probe = + "(function(){" + + "if(document.documentElement&&document.documentElement.dataset['$flag']==='true')" + + "{return Math.max(document.body?document.body.scrollHeight:1,1);}" + + "return 0;})()" + pollDomReady(probe) + } + + private fun pollDomReady(probe: String) { + if (!isConnected || isDead || isPageResolved) return + webView.evaluateJavascript(probe) { result -> onDomReadyProbeResult(probe, result) } + } + + @VisibleForTesting + internal fun onDomReadyProbeResult(probe: String, result: String?) { + if (!isConnected || isDead || isPageResolved) return + val height = result?.toDoubleOrNull() ?: 0.0 + if (height > 0) { + isPageResolved = true + onMessage?.invoke(TempEmbeddedBlockPageMessage.Ready(heightCssPx = height)) + return + } + mainHandler.postDelayed({ pollDomReady(probe) }, DOM_READY_POLL_INTERVAL.interval) + } + + private fun addBridgeIfNeeded() { + if (isBridgeAdded) return + + webView.addJavascriptInterface(EmbeddedBlockPageBridge(this, mainHandler), bridgeName) + isBridgeAdded = true + } + + // Known gap for the shared-bridge task: a one-shot `ready` posted into the pause window is + // dropped here and never re-sent; the DOM protocol self-heals on resume, the bridge does not. + private fun receive(body: String) { + if (!isConnected) return + + val payload = runCatching { JSONObject(body) }.getOrNull() + if (payload == null) { + mindboxLogW("[EmbeddedBlock] Malformed block page message: ${body.logPreview()}") + return + } + + val message = TempEmbeddedBlockPageMessage.parse(payload) + if (message == null) { + val consumer = onMechanicMessage + if (consumer == null) { + mindboxLogI("[EmbeddedBlock] Page message with no consumer: ${body.logPreview()}") + } else { + consumer.invoke(payload) + } + return + } + if (message is TempEmbeddedBlockPageMessage.Ready) isPageResolved = true + onMessage?.invoke(message) + } + + // The body is the page's own text: untrusted, up to MAX_MESSAGE_LENGTH and free to carry + // newlines. Logged raw, one message would break the log into dozens of lines and bury the tag. + private fun String.logPreview(): String = + take(BODY_LOG_LIMIT).replace('\n', ' ').replace('\r', ' ') + + private class EmbeddedBlockPageBridge( + page: EmbeddedBlockWebViewPage, + private val mainHandler: Handler, + ) { + + // The WebView holds the bridge strongly, so the bridge points back weakly. + private val pageRef = WeakReference(page) + + private var windowStart = Timestamp(0L) + private var windowCount = 0 + + @JavascriptInterface + fun postMessage(json: String) { + if (json.length > MAX_MESSAGE_LENGTH) return + // A page looping postMessage would queue a main-looper runnable per call and ANR the + // whole HOST app. + if (!allowMessage()) return + mainHandler.post { pageRef.get()?.receive(json) } + } + + @Synchronized + private fun allowMessage(): Boolean { + val now = Timestamp(SystemClock.uptimeMillis()) + if ((now - windowStart).ms > RATE_WINDOW.interval) { + windowStart = now + windowCount = 0 + } + windowCount++ + if (windowCount == MAX_MESSAGES_PER_WINDOW + 1) { + mindboxLogW("[EmbeddedBlock] Block page floods the bridge, dropping messages") + } + return windowCount <= MAX_MESSAGES_PER_WINDOW + } + } + + private companion object { + + private const val MAX_MESSAGE_LENGTH = 16_384 + private const val BODY_LOG_LIMIT = 200 + private const val URL_LOG_LIMIT = 200 + private val DOM_READY_POLL_INTERVAL = Milliseconds(200L) + private val RATE_WINDOW = Milliseconds(1_000L) + private const val MAX_MESSAGES_PER_WINDOW = 30 + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProvider.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProvider.kt new file mode 100644 index 00000000..a7897b14 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProvider.kt @@ -0,0 +1,113 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import android.view.View +import cloud.mindbox.mobile_sdk.embedded.EmbeddedBlockState +import cloud.mindbox.mobile_sdk.embedded.EmbeddedContentProvider +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW + +internal class EmbeddedBlockWebViewProvider( + private val page: EmbeddedBlockPage, +) : EmbeddedContentProvider { + + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + + override val contentView: View? + get() = if (isReady) page.view else null + + private var isLoaded = false + private var isActive = false + private var isReady = false + private var lastState: EmbeddedBlockState = EmbeddedBlockState.Loading + + init { + page.onMessage = { message -> handle(message) } + page.onPageError = { description -> + // Latched even while paused: the system is free to kill the renderer of a backgrounded + // WebView, and the next start must replay Failed — not a stale Ready over a dead page. + mindboxLogW("[EmbeddedBlock] Block page failed: $description") + isReady = false + lastState = EmbeddedBlockState.Failed + if (isActive) onStateChange?.invoke(EmbeddedBlockState.Failed) + page.pause() + } + } + + override fun start() { + isActive = true + if (isLoaded) { + page.resume() + report(lastState) + return + } + isLoaded = true + isReady = false + report(EmbeddedBlockState.Loading) + page.load() + } + + override fun pause() { + isActive = false + page.pause() + } + + override fun release() { + isActive = false + isReady = false + page.release() + } + + private fun handle(message: TempEmbeddedBlockPageMessage) { + if (!isActive) return + + when (message) { + is TempEmbeddedBlockPageMessage.Ready -> applyHeight(message.heightCssPx) + + is TempEmbeddedBlockPageMessage.HeightChanged -> mindboxLogI( + "[EmbeddedBlock] Ignored heightChanged(${message.heightCssPx}): " + + "the host owns the block height", + ) + + is TempEmbeddedBlockPageMessage.Empty -> { + mindboxLogI("[EmbeddedBlock] Block page says it has nothing to show") + isReady = false + report(EmbeddedBlockState.Empty) + page.pause() + } + } + } + + private fun applyHeight(heightCssPx: Double) { + if (heightCssPx <= 0) { + mindboxLogW("[EmbeddedBlock] Block page reported zero height in ready, treating it as broken") + isReady = false + report(EmbeddedBlockState.Failed) + page.pause() + return + } + + // A height no real block can have is a broken or hostile page: honoring it would hand one + // JS message the power to blow up the host's measure pass. + if (heightCssPx > MAX_BLOCK_HEIGHT_CSS_PX) { + mindboxLogW("[EmbeddedBlock] Block page reported implausible height $heightCssPx, collapsing") + isReady = false + report(EmbeddedBlockState.Failed) + page.pause() + return + } + + // The number is never applied — the host owns the block size; a plausible height only + // proves the page rendered something real. + isReady = true + report(EmbeddedBlockState.Ready) + } + + private fun report(state: EmbeddedBlockState) { + lastState = state + onStateChange?.invoke(state) + } + + private companion object { + private const val MAX_BLOCK_HEIGHT_CSS_PX = 4096.0 + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageContract.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageContract.kt new file mode 100644 index 00000000..2235e28a --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageContract.kt @@ -0,0 +1,20 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import cloud.mindbox.mobile_sdk.embedded.mock.TempEmbeddedBlockUsage + +internal object TempEmbeddedBlockPageContract { + + init { + TempEmbeddedBlockUsage.report("temporary page bridge contract (own bridge name, not the shared one)") + } + + // Matches the iOS handler name: one page speaks to both platforms, so the name it posts to + // cannot differ between them. Renaming it again means renaming it on iOS and in every + // published page, so it happens together with the web team. + const val BRIDGE_NAME: String = "mindboxEmbeddedBlock" + + // Android-only, and specific to the stories page that does not implement the contract yet: + // it never posts anything, so readiness is read off this DOM flag instead. Goes away with the + // first page that sends `ready` by itself. + const val DOM_READY_FLAG: String = "storiesReady" +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageMessage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageMessage.kt new file mode 100644 index 00000000..dce8c7e6 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageMessage.kt @@ -0,0 +1,51 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import cloud.mindbox.mobile_sdk.embedded.mock.TempEmbeddedBlockUsage +import org.json.JSONObject + +internal sealed class TempEmbeddedBlockPageMessage { + + data class Ready(val heightCssPx: Double) : TempEmbeddedBlockPageMessage() + + data class HeightChanged(val heightCssPx: Double) : TempEmbeddedBlockPageMessage() + + /** + * The page has nothing to put in the block — its targeting matched nothing, the mechanic is + * switched off. Said explicitly, so that it is never confused with a page that rendered + * nothing because it is broken. + */ + data object Empty : TempEmbeddedBlockPageMessage() + + companion object { + + // On the class initializer, not on parse(): the DOM-flag protocol builds Ready directly and + // never parses anything, so a marker inside parse() would leave that whole path silent. + init { + TempEmbeddedBlockUsage.report("temporary page message protocol (the shared JS bridge replaces it)") + } + + private const val KEY_TYPE = "type" + private const val KEY_HEIGHT = "height" + + fun parse(body: String): TempEmbeddedBlockPageMessage? { + val payload = runCatching { JSONObject(body) }.getOrNull() ?: return null + return parse(payload) + } + + // Null means "not part of the common protocol": a mechanic-dialect message, or a + // malformed one — the page may evolve ahead of the SDK. + fun parse(payload: JSONObject): TempEmbeddedBlockPageMessage? = + when (payload.optString(KEY_TYPE)) { + "ready" -> height(payload)?.let { Ready(it) } + "heightChanged" -> height(payload)?.let { HeightChanged(it) } + "empty" -> Empty + else -> null + } + + private fun height(payload: JSONObject): Double? { + if (!payload.has(KEY_HEIGHT)) return null + val height = payload.optDouble(KEY_HEIGHT) + return if (height.isFinite()) height else null + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt index 4a3c7141..3b975e32 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/SessionStorageManager.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.TrackVisitData import cloud.mindbox.mobile_sdk.utils.TimeProvider import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicLong import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -35,7 +36,7 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { val lastTrackVisitSendTime: AtomicLong = AtomicLong(0L) - private val sessionExpirationListeners = mutableListOf() + private val sessionExpirationListeners = CopyOnWriteArrayList() private var wasSessionExpiredOnLastCheck: Boolean = false @@ -43,6 +44,10 @@ internal class SessionStorageManager(private val timeProvider: TimeProvider) { sessionExpirationListeners.add(listener) } + fun removeSessionExpirationListener(listener: SessionExpirationListener) { + sessionExpirationListeners.remove(listener) + } + fun hasSessionExpired() { wasSessionExpiredOnLastCheck = false val currentTime = timeProvider.currentTimeMillis() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 770335e6..143648cf 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -27,6 +27,8 @@ import cloud.mindbox.mobile_sdk.models.TimeSpan import cloud.mindbox.mobile_sdk.models.operation.response.* import cloud.mindbox.mobile_sdk.monitoring.data.validators.MonitoringValidator import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.embedded.mock.TempEmbeddedBlockUsage +import cloud.mindbox.mobile_sdk.embedded.mock.TempEmbeddedBlocksMockConfigSection import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull @@ -71,8 +73,16 @@ internal class MobileConfigRepositoryImpl( override suspend fun fetchMobileConfig() { val configuration = DbManager.listenConfigurations().first() - MindboxPreferences.inAppConfig = gatewayManager.fetchMobileConfig( - configuration = configuration + // TODO(MOBILE-324): temporary, must not reach develop — the backend does not send the + // inlineBlocks section yet; drop the inject() wrapper together with the mock page once + // the real contract lands. This is the one place where the temporary code reaches into a + // file that is not itself temporary, so it reports itself even in a release build: the + // wrapper being here at all is what has to go, whether or not it injects anything. + TempEmbeddedBlockUsage.report("temporary mock config injection still wrapping fetchMobileConfig()") + MindboxPreferences.inAppConfig = TempEmbeddedBlocksMockConfigSection.inject( + gatewayManager.fetchMobileConfig( + configuration = configuration + ) ) MindboxPreferences.inAppConfigUpdatedTime = System.currentTimeMillis() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt index b407f808..f664a567 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerImpl.kt @@ -9,6 +9,9 @@ internal class InAppEventManagerImpl : InAppEventManager { override fun isValidInAppEvent(event: InAppEventType): Boolean { val isAppStartUp = event is InAppEventType.AppStartup + // An embedded place asking for content is a trigger like any other: the config decides + // what goes into the place, and it is matched by the operation name of this very event. + val isEmbeddedPlaceRequested = event is InAppEventType.EmbeddedPlaceRequested val isOrdinalEvent = event is InAppEventType.OrdinalEvent && (event.eventType is EventType.SyncOperation || event.eventType is EventType.AsyncOperation) val isNotInAppEvent = (listOf( @@ -18,6 +21,7 @@ internal class InAppEventManagerImpl : InAppEventManager { MindboxEventManager.IN_APP_OPERATION_SHOW_FAILURE_TYPE ).contains(event.name).not()) return isAppStartUp || + isEmbeddedPlaceRequested || (isOrdinalEvent && isNotInAppEvent) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 027eae1e..3a532b92 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -29,6 +29,7 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.utils.Constants import cloud.mindbox.mobile_sdk.utils.TimeProvider import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionGesture import cloud.mindbox.mobile_sdk.inapp.presentation.view.motion.MotionService @@ -67,7 +68,6 @@ internal class WebViewInAppViewHolder( ) : AbstractInAppViewHolder(wrapper, controller, inAppCallback) { companion object { - private const val INIT_TIMEOUT_MS = 7_000L private const val TIMER = "CLOSE_INAPP_TIMER" private const val JS_RETURN = "true" private const val JS_BRIDGE_CLASS = "window.bridgeMessagesHandlers" @@ -855,8 +855,8 @@ internal class WebViewInAppViewHolder( private fun startTimer(onTimeOut: () -> Unit) { Stopwatch.start(TIMER) closeInappTimer = timer( - initialDelay = INIT_TIMEOUT_MS, - period = INIT_TIMEOUT_MS, + initialDelay = Constants.WebView.readyTimeout.interval, + period = Constants.WebView.readyTimeout.interval, action = { onTimeOut() } ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt index 7f212087..e0eef542 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt @@ -122,6 +122,15 @@ internal object MindboxEventManager { return InAppEventType.AppStartup } + fun embeddedPlaceRequested(placeSystemName: String): Unit = loggingRunCatching { + val isEmitted = eventFlow.tryEmit(InAppEventType.EmbeddedPlaceRequested(placeSystemName)) + if (isEmitted) { + mindboxLogI("[EmbeddedBlock] Place '$placeSystemName' requested content") + } else { + mindboxLogW("[EmbeddedBlock] Place '$placeSystemName' request dropped: the event buffer is full") + } + } + private fun asyncOperation( context: Context, event: Event, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Event.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Event.kt index 567d05ff..ca676a9e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Event.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/Event.kt @@ -80,4 +80,10 @@ internal sealed class InAppEventType(val name: String) { data object AppStartup : InAppEventType("appStartup") class OrdinalEvent(val eventType: EventType, val body: String? = null) : InAppEventType(eventType.operation) + + data class EmbeddedPlaceRequested(val placeSystemName: String) : InAppEventType(EVENT_NAME) { + internal companion object { + const val EVENT_NAME = "embeddedPlaceRequested" + } + } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt index 73d9c1eb..453f5030 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt @@ -1,5 +1,7 @@ package cloud.mindbox.mobile_sdk.utils +import cloud.mindbox.mobile_sdk.models.Milliseconds + internal object Constants { internal const val SDK_VERSION_NUMERIC = 12 internal const val TYPE_JSON_NAME = "\$type" @@ -9,4 +11,8 @@ internal object Constants { internal const val APP_UID_NAME = "app_uid" internal const val SCHEME_PACKAGE = "package" internal const val SDK_VERSION_CODE = 4 + + internal object WebView { + internal val readyTimeout = Milliseconds(7_000L) + } } diff --git a/sdk/src/main/res/values-night/colors.xml b/sdk/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..6f504c87 --- /dev/null +++ b/sdk/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #2C2C2E + diff --git a/sdk/src/main/res/values/attrs.xml b/sdk/src/main/res/values/attrs.xml new file mode 100644 index 00000000..29bc9880 --- /dev/null +++ b/sdk/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/sdk/src/main/res/values/colors.xml b/sdk/src/main/res/values/colors.xml index 96a6b661..d4bc9bf4 100644 --- a/sdk/src/main/res/values/colors.xml +++ b/sdk/src/main/res/values/colors.xml @@ -2,4 +2,6 @@ @android:color/transparent #80000000 + + #E8E8EC \ No newline at end of file diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt new file mode 100644 index 00000000..26770939 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt @@ -0,0 +1,561 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.os.Looper +import android.view.View +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.di.modules.AppModule +import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.models.Milliseconds +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.time.Duration + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class EmbeddedBlockContentControllerTest { + + private class FakeProvider : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + var view: View? = null + override val contentView: View? + get() = view + var startCount = 0 + var pauseCount = 0 + var releaseCount = 0 + + override fun start() { + startCount++ + } + + override fun pause() { + pauseCount++ + } + + override fun release() { + releaseCount++ + } + + fun report(state: EmbeddedBlockState) { + onStateChange?.invoke(state) + } + } + + private val configFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val sessionListeners = mutableListOf<() -> Unit>() + private val requestedPlaces = mutableListOf() + private val states = mutableListOf() + + private val sessionStorage: SessionStorageManager = mockk(relaxed = true) + + @Before + fun setUp() { + mockkObject(Mindbox) + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher()) + + mockkObject(MindboxPreferences) + every { MindboxPreferences.inAppConfigFlow } returns configFlow + + mockkObject(MindboxEventManager) + every { MindboxEventManager.embeddedPlaceRequested(any()) } answers { + requestedPlaces.add(firstArg()) + Unit + } + + every { sessionStorage.addSessionExpirationListener(any()) } answers { + sessionListeners.add(firstArg()) + Unit + } + every { sessionStorage.removeSessionExpirationListener(any()) } answers { + sessionListeners.remove(firstArg()) + Unit + } + val appModule: AppModule = mockk(relaxed = true) + every { appModule.sessionStorageManager } returns sessionStorage + mockkObject(MindboxDI) + every { MindboxDI.isInitialized() } returns true + every { MindboxDI.appModule } returns appModule + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun controller( + timeout: Milliseconds = Milliseconds(10_000L), + placeSystemName: String? = "test-place", + resolve: () -> EmbeddedContentResolution, + ): EmbeddedBlockContentController = EmbeddedBlockContentController( + resolveFactory = resolve, + placeSystemName = placeSystemName, + readyTimeout = timeout, + ).apply { onStateChange = { states.add(it) } } + + private fun content(provider: EmbeddedContentProvider): EmbeddedContentResolution = + EmbeddedContentResolution.Content(provider) + + /** The SDK is not up yet: no graph to take a session listener from. */ + private fun withoutDi() { + every { MindboxDI.isInitialized() } returns false + } + + private fun deliverConfig() { + configFlow.tryEmit("{}") + shadowOf(Looper.getMainLooper()).idle() + } + + private fun expireSession() { + sessionListeners.toList().forEach { it() } + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `resolved content is started and its states are forwarded`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + + controller.start() + provider.report(EmbeddedBlockState.Ready) + + assertEquals(1, provider.startCount) + assertEquals(EmbeddedBlockState.Ready, states.last()) + } + + @Test + fun `the content view is the provider's own`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + controller.start() + + assertNull(controller.contentView) + + provider.view = View(ApplicationProvider.getApplicationContext()) + assertNotNull(controller.contentView) + } + + @Test + fun `a place with nothing configured reports Empty`() { + val controller = controller { EmbeddedContentResolution.NothingToShow } + + controller.start() + + assertEquals(listOf(EmbeddedBlockState.Empty), states) + assertNull(controller.contentView) + } + + @Test + fun `a resolution that throws reports Failed`() { + val controller = controller { error("factory blew up") } + + controller.start() + + assertEquals(listOf(EmbeddedBlockState.Failed), states) + } + + @Test + fun `a config that has not arrived keeps the block loading instead of failing it`() { + val controller = controller { EmbeddedContentResolution.NotReadyYet } + + controller.start() + + // The SDK may still be starting up: an absent config is not an empty place, and the + // block must not burn its one public failure on it. + assertEquals(listOf(EmbeddedBlockState.Loading), states) + assertEquals(1, configFlow.subscriptionCount.value) + } + + @Test + fun `the arriving config fills the place without a re-attach`() { + val provider = FakeProvider() + var ready = false + val controller = controller { + if (ready) content(provider) else EmbeddedContentResolution.NotReadyYet + } + controller.start() + assertEquals(0, provider.startCount) + + ready = true + deliverConfig() + + assertEquals(1, provider.startCount) + // The subscription did its job and is dropped — the block is not a permanent listener. + assertEquals(0, configFlow.subscriptionCount.value) + } + + @Test + fun `a config arriving while the block is off screen resolves the content but keeps it paused`() { + val provider = FakeProvider() + var ready = false + val controller = controller { + if (ready) content(provider) else EmbeddedContentResolution.NotReadyYet + } + controller.start() + controller.pause() + + ready = true + deliverConfig() + + // Resolved so the next appearance is instant, but nothing runs off screen. + assertEquals(0, provider.startCount) + assertEquals(1, provider.pauseCount) + + controller.start() + assertEquals(1, provider.startCount) + } + + @Test + fun `a config arriving after release cannot resurrect the block`() { + val provider = FakeProvider() + var ready = false + val controller = controller { + if (ready) content(provider) else EmbeddedContentResolution.NotReadyYet + } + controller.start() + controller.release() + + ready = true + deliverConfig() + + // The host screen is gone: building a WebView for it would leak it outright. + assertEquals(0, provider.startCount) + assertEquals(0, configFlow.subscriptionCount.value) + } + + @Test + fun `waiting for the config takes exactly one subscription`() { + val controller = controller { EmbeddedContentResolution.NotReadyYet } + + controller.start() + controller.pause() + controller.start() + + assertEquals(1, configFlow.subscriptionCount.value) + } + + @Test + fun `a config source that blows up does not break the block`() { + every { MindboxPreferences.inAppConfigFlow } throws IllegalStateException("no preferences") + val controller = controller { EmbeddedContentResolution.NotReadyYet } + + controller.start() + controller.release() + + assertEquals(listOf(EmbeddedBlockState.Loading), states) + } + + @Test + fun `resolved content is reused across appearances`() { + val provider = FakeProvider() + var resolves = 0 + val controller = controller { + resolves++ + content(provider) + } + + controller.start() + controller.pause() + controller.start() + + // A pause is not a teardown: the same page is resumed, never resolved twice. + assertEquals(1, resolves) + assertEquals(2, provider.startCount) + } + + @Test + fun `the place asks for content only while it has none`() { + val provider = FakeProvider() + var ready = false + val controller = controller { + if (ready) content(provider) else EmbeddedContentResolution.NotReadyYet + } + + controller.start() + controller.pause() + controller.start() + assertEquals(listOf("test-place", "test-place"), requestedPlaces) + + // Once the place is filled there is nothing left to ask for. + ready = true + deliverConfig() + controller.pause() + controller.start() + + assertEquals(listOf("test-place", "test-place"), requestedPlaces) + } + + @Test + fun `a nameless block has nothing to ask about`() { + val controller = controller(placeSystemName = null) { EmbeddedContentResolution.NothingToShow } + + controller.start() + + assertTrue(requestedPlaces.isEmpty()) + } + + @Test + fun `pause quiets the content`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + controller.start() + + controller.pause() + + assertEquals(1, provider.pauseCount) + } + + @Test + fun `release frees the content and unsubscribes from everything`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + controller.start() + + controller.release() + + assertEquals(1, provider.releaseCount) + assertTrue(sessionListeners.isEmpty()) + } + + @Test + fun `a released block stays released`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + controller.start() + controller.release() + states.clear() + + controller.start() + expireSession() + + // release() is the host screen's death — nothing may bring the block back. + assertEquals(1, provider.startCount) + assertTrue(states.isEmpty()) + } + + @Test + fun `a new session reloads the content live`() { + val providers = mutableListOf() + val controller = controller { content(FakeProvider().also { providers.add(it) }) } + controller.start() + + expireSession() + + // The old page is released for good (not paused — nobody will resume it) and a fresh + // one takes over without a re-attach. + assertEquals(2, providers.size) + assertEquals(1, providers[0].releaseCount) + assertEquals(1, providers[1].startCount) + } + + @Test + fun `a new session while off screen drops the content and reloads it on the next appearance`() { + val providers = mutableListOf() + val controller = controller { content(FakeProvider().also { providers.add(it) }) } + controller.start() + controller.pause() + + expireSession() + + // Nothing loads off screen… + assertEquals(1, providers.size) + assertEquals(1, providers[0].releaseCount) + + controller.start() + + // …but the stale content is gone, so the next appearance resolves fresh. + assertEquals(2, providers.size) + assertEquals(1, providers[1].startCount) + } + + @Test + fun `a new session asks the place what to show now`() { + val controller = controller { content(FakeProvider()) } + controller.start() + requestedPlaces.clear() + + expireSession() + + assertEquals(listOf("test-place"), requestedPlaces) + } + + @Test + fun `a block that started before the SDK still catches the session listener later`() { + withoutDi() + val provider = FakeProvider() + var ready = false + val controller = controller { + if (ready) content(provider) else EmbeddedContentResolution.NotReadyYet + } + controller.start() + assertTrue(sessionListeners.isEmpty()) + + // A config can only arrive through a live SDK, so the graph is up by the time it does. + every { MindboxDI.isInitialized() } returns true + ready = true + deliverConfig() + + // Without this the block would never reload on a new session: it sits on screen and has + // no next appearance to retry the subscription on. + assertEquals(1, sessionListeners.size) + } + + @Test + fun `a missing DI graph does not break the block`() { + withoutDi() + val provider = FakeProvider() + val controller = controller { content(provider) } + + controller.start() + controller.release() + + assertEquals(1, provider.startCount) + assertEquals(1, provider.releaseCount) + } + + @Test + fun `a silent page times out into a failure and stops listening for sessions`() { + val provider = FakeProvider() + val controller = controller(timeout = Milliseconds(1_000L)) { content(provider) } + controller.start() + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1_100L)) + + // Paused before the failure is reported, so a late page cannot resurrect the block — + // and a new session must not restart a block the host already saw fail. + assertEquals(1, provider.pauseCount) + assertEquals(EmbeddedBlockState.Failed, states.last()) + assertTrue(sessionListeners.isEmpty()) + } + + @Test + fun `a page that answers in time never times out`() { + val provider = FakeProvider() + val controller = controller(timeout = Milliseconds(1_000L)) { content(provider) } + controller.start() + provider.report(EmbeddedBlockState.Ready) + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2_000L)) + + assertEquals(0, provider.pauseCount) + assertEquals(EmbeddedBlockState.Ready, states.last()) + } + + @Test + fun `leaving the screen cancels the pending timeout`() { + val provider = FakeProvider() + val controller = controller(timeout = Milliseconds(1_000L)) { content(provider) } + controller.start() + + controller.pause() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2_000L)) + + // Only the pause from leaving the screen — a block nobody looks at cannot be late. + assertEquals(1, provider.pauseCount) + assertTrue(states.none { it is EmbeddedBlockState.Failed }) + } + + @Test + fun `waiting for the config is not on the page's clock`() { + val controller = controller(timeout = Milliseconds(1_000L)) { + EmbeddedContentResolution.NotReadyYet + } + controller.start() + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000L)) + + // The budget covers rendering a page, and there is no page yet: an SDK that starts up + // slowly must not turn every block on the screen into a failure. + assertEquals(listOf(EmbeddedBlockState.Loading), states) + } + + @Test + fun `content that replaces the old one gets a full budget, not its leftovers`() { + val controller = controller(timeout = Milliseconds(1_000L)) { content(FakeProvider()) } + controller.start() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(600L)) + + // The session turns over mid-load: the old page's clock dies with it. + expireSession() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(600L)) + + // The old budget would have expired by now; the fresh page still has time left. + assertTrue(states.none { it is EmbeddedBlockState.Failed }) + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500L)) + assertEquals(EmbeddedBlockState.Failed, states.last()) + } + + @Test + fun `the same loading state is not reported twice`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + + controller.start() + provider.report(EmbeddedBlockState.Loading) + provider.report(EmbeddedBlockState.Loading) + + // The container turns a Loading into a placeholder swap; repeating it is pure churn. + assertEquals(listOf(EmbeddedBlockState.Loading), states) + } + + @Test + fun `a page reporting its height again does not re-report Ready`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + controller.start() + + provider.report(EmbeddedBlockState.Ready) + repeat(5) { provider.report(EmbeddedBlockState.Ready) } + + // A live page reports its height on every relayout; the container is already showing it. + assertEquals(listOf(EmbeddedBlockState.Ready), states) + } + + @Test + fun `a resumed page does not re-report the state the container already shows`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + controller.start() + provider.report(EmbeddedBlockState.Ready) + controller.pause() + states.clear() + + // start() resumes the page, and the page replays where it stands. + controller.start() + provider.report(EmbeddedBlockState.Ready) + + assertTrue(states.isEmpty()) + } + + @Test + fun `loading after a resolved state is reported again`() { + val provider = FakeProvider() + val controller = controller { content(provider) } + controller.start() + provider.report(EmbeddedBlockState.Ready) + + provider.report(EmbeddedBlockState.Loading) + + // A reload is real news for the host: the block goes back to the placeholder. + assertEquals(EmbeddedBlockState.Loading, states.last()) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt new file mode 100644 index 00000000..79829d2f --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt @@ -0,0 +1,77 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EmbeddedBlockContentFactoryTest { + + private val context = ApplicationProvider.getApplicationContext() + + private fun resolutionFor(rawConfig: String, place: String = "place"): EmbeddedContentResolution = + EmbeddedBlockContentFactory { rawConfig }.create(context, place) + + @Test + fun `a placement for the place builds content`() { + // No mechanic type any more: a configured place always gets content, and what the page + // draws is the page's business. + val resolution = resolutionFor("""{"inlineBlocks":[{"placeSystemName":"place","pageUrl":null}]}""") + + assertTrue(resolution is EmbeddedContentResolution.Content) + } + + @Test + fun `the first candidate for the place wins`() { + val resolution = resolutionFor( + """ + {"inlineBlocks":[ + {"placeSystemName":"place","pageUrl":"https://mindbox.ru/a"}, + {"placeSystemName":"place","pageUrl":"https://mindbox.ru/b"} + ]} + """.trimIndent(), + ) + + // Which candidate wins becomes a targeting decision with the in-app migration; today the + // contract is only "one of them, never a crash". + assertTrue(resolution is EmbeddedContentResolution.Content) + } + + @Test + fun `no placement for the place means nothing to show`() { + val resolution = resolutionFor("""{"inlineBlocks":[{"placeSystemName":"other","pageUrl":null}]}""") + + assertTrue(resolution is EmbeddedContentResolution.NothingToShow) + } + + @Test + fun `a config without the section means nothing to show`() { + // The config arrived and simply has no blocks in it — a settled answer, not a wait. + assertTrue(resolutionFor("""{"inapps":[]}""") is EmbeddedContentResolution.NothingToShow) + } + + @Test + fun `a malformed config means nothing to show`() { + assertTrue(resolutionFor("not a json") is EmbeddedContentResolution.NothingToShow) + } + + @Test + fun `an absent config is a wait, not an empty place`() { + // The SDK may still be starting up. Telling the block "nothing here" would collapse it + // for good; the block has to keep its placeholder until the config actually lands. + assertTrue(resolutionFor("") is EmbeddedContentResolution.NotReadyYet) + } + + @Test + fun `a nameless block has nothing to resolve`() { + assertTrue( + EmbeddedBlockContentFactory.resolve(context, null) is EmbeddedContentResolution.NothingToShow, + ) + assertTrue( + EmbeddedBlockContentFactory.resolve(context, " ") is EmbeddedContentResolution.NothingToShow, + ) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt new file mode 100644 index 00000000..ea5c56ad --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt @@ -0,0 +1,128 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.app.Activity +import android.os.Looper +import android.view.View +import android.widget.LinearLayout +import cloud.mindbox.mobile_sdk.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.time.Duration + +/** + * The public-constructor path: the block resolves its content through the real config lookup + * (no injected fake), exactly as a host app creates it. Nothing is initialized in this test + * process — which is precisely the case a host hits when it inflates a screen before the SDK + * finished starting up. + */ +@RunWith(RobolectricTestRunner::class) +class MindboxEmbeddedBlockViewLookupTest { + + private class RecordingListener : MindboxEmbeddedBlockListener { + val events = mutableListOf() + + override fun onLoad(view: MindboxEmbeddedBlockView) { + events.add("load") + } + + override fun onFail(view: MindboxEmbeddedBlockView) { + events.add("fail") + } + } + + private val activity: Activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + private fun attach(view: MindboxEmbeddedBlockView) { + activity.setContentView( + LinearLayout(activity).apply { addView(view, 500, 300) }, + ) + shadowOf(Looper.getMainLooper()).idle() + dispatchWindowVisibility(view, View.VISIBLE) + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `a block created before the config arrived keeps waiting instead of collapsing`() { + val view = MindboxEmbeddedBlockView(activity, "main-screen-top") + val listener = RecordingListener() + view.setListener(listener) + + attach(view) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(30L)) + + // No config yet is not an empty place: the block holds its placeholder and stays silent + // rather than burning its one public failure on a slow start-up. + assertEquals(View.VISIBLE, view.visibility) + assertTrue(listener.events.isEmpty()) + assertEquals("main-screen-top", view.placeSystemName) + } + + @Test + fun `a block without a place name hides itself`() { + // XML without the attribute (or a programmatic view with no name): nothing to match + // against the config — the block hides itself, never a crash. + val view = MindboxEmbeddedBlockView(activity) + val listener = RecordingListener() + view.setListener(listener) + + attach(view) + + assertEquals(View.GONE, view.visibility) + assertEquals(listOf("fail"), listener.events) + assertNull(view.placeSystemName) + } + + @Test + fun `the place name is read from the xml attribute`() { + // The layout path: a host marks the place in XML and never touches the SDK in code. + val attrs = Robolectric.buildAttributeSet() + .addAttribute(R.attr.mindboxPlaceSystemName, "main-screen-top") + .build() + + val view = MindboxEmbeddedBlockView(activity, attrs) + + assertEquals("main-screen-top", view.placeSystemName) + } + + @Test + fun `a layout without the attribute leaves the block nameless`() { + val view = MindboxEmbeddedBlockView(activity, Robolectric.buildAttributeSet().build()) + + assertNull(view.placeSystemName) + } + + @Test + fun `a blank place name is the same as none`() { + val view = MindboxEmbeddedBlockView(activity, " ") + + attach(view) + + assertEquals(View.GONE, view.visibility) + assertNull(view.placeSystemName) + } + + @Test + fun `the block behaves with no listener at all`() { + // The show/hide behavior belongs to the block, not to the host's callbacks. + val view = MindboxEmbeddedBlockView(activity, "main-screen-top") + + attach(view) + + assertEquals(View.VISIBLE, view.visibility) + } + + @Test + fun `releasing a block that never resolved anything is safe`() { + val view = MindboxEmbeddedBlockView(activity, "main-screen-top") + attach(view) + + (view.parent as LinearLayout).removeView(view) + shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt new file mode 100644 index 00000000..df0f5edc --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt @@ -0,0 +1,718 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.app.Activity +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.setViewTreeLifecycleOwner +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.managers.MindboxEventManager +import cloud.mindbox.mobile_sdk.models.Milliseconds +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +class MindboxEmbeddedBlockViewTest { + + /** A pure recorder — the show/hide behavior belongs to the view itself. */ + private class RecordingListener : MindboxEmbeddedBlockListener { + val events = mutableListOf() + + override fun onLoad(view: MindboxEmbeddedBlockView) { + events.add("load") + } + + override fun onFail(view: MindboxEmbeddedBlockView) { + events.add("fail") + } + } + + /** + * Mirrors the real provider contract: contentView is gone the moment the content is not + * Ready, start() reports a state synchronously (Loading on a fresh load, the current state + * on a resume), pause() keeps the content. + */ + private class FakeProvider : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + var readyView: View? = null + override val contentView: View? + get() = if (isReady) readyView else null + private var isReady = false + private var lastState: EmbeddedBlockState = EmbeddedBlockState.Loading + var startCount = 0 + var pauseCount = 0 + var releaseCount = 0 + + override fun start() { + startCount++ + onStateChange?.invoke(lastState) + } + + override fun pause() { + pauseCount++ + } + + override fun release() { + releaseCount++ + } + + fun report(state: EmbeddedBlockState) { + lastState = state + isReady = state is EmbeddedBlockState.Ready + onStateChange?.invoke(state) + } + } + + private val activityController: ActivityController = + Robolectric.buildActivity(Activity::class.java).setup() + private val activity: Activity = activityController.get() + private val provider = FakeProvider() + private val listener = RecordingListener() + + /** Built the way a host builds it: configured first, put on screen afterwards. */ + private fun blockView(timeout: Milliseconds = Milliseconds(10_000L)): MindboxEmbeddedBlockView = + MindboxEmbeddedBlockView( + activity, + null, + "test-place", + EmbeddedBlockContentController( + resolveFactory = { EmbeddedContentResolution.Content(provider) }, + placeSystemName = "test-place", + readyTimeout = timeout, + ), + ) + + private fun attach(view: MindboxEmbeddedBlockView) { + activity.setContentView( + // The host owns the size: an explicit fixed height, as the contract demands. + LinearLayout(activity).apply { addView(view, 500, 300) }, + ) + showWindow(view) + } + + /** + * Robolectric leaves every window at GONE, so the block — which starts its content only once + * the window is actually visible — would never start. On a device the framework dispatches + * this down the tree right after the attach; here the test stands in for it. + */ + private fun showWindow(view: MindboxEmbeddedBlockView) { + shadowOf(Looper.getMainLooper()).idle() + dispatchWindowVisibility(view, View.VISIBLE) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun attachedView(timeout: Milliseconds = Milliseconds(10_000L)): MindboxEmbeddedBlockView = + blockView(timeout).also { attach(it) } + + /** The single state child the container is currently showing. */ + private fun shownChild(view: MindboxEmbeddedBlockView): View? { + assertTrue("one state child at a time", view.childCount <= 1) + return if (view.childCount == 1) view.getChildAt(0) else null + } + + @Test + fun `exposes the place it was created for`() { + assertEquals("test-place", attachedView().placeSystemName) + } + + @Test + fun `starts the content on attach and pauses it on detach`() { + val view = attachedView() + + assertEquals(1, provider.startCount) + + (view.parent as LinearLayout).removeView(view) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, provider.pauseCount) + } + + @Test + fun `shows the default placeholder while loading`() { + val view = attachedView() + + // The default placeholder is an SDK-internal view — anything shown means the frame is + // not empty while loading. + assertTrue(shownChild(view) != null) + } + + @Test + fun `a custom placeholder replaces the default one`() { + val custom = View(activity) + val view = blockView() + + view.setPlaceholderView(custom) + attach(view) + + assertSame(custom, shownChild(view)) + } + + @Test + fun `a placeholder set while loading swaps in right away`() { + val custom = View(activity) + val view = attachedView() + + view.setPlaceholderView(custom) + + // The block is loading right now — the host should not have to wait for the next state + // change to see its own placeholder. + assertSame(custom, shownChild(view)) + } + + @Test + fun `dropping the placeholder while loading brings the default placeholder back`() { + val view = attachedView() + val custom = View(activity) + view.setPlaceholderView(custom) + + view.setPlaceholderView(null) + + val child = shownChild(view) + assertTrue(child != null && child !== custom) + } + + @Test + fun `a placeholder set after the content arrived does not disturb it`() { + val view = attachedView() + val content = View(activity) + provider.readyView = content + provider.report(EmbeddedBlockState.Ready) + + view.setPlaceholderView(View(activity)) + + // Only the loading frame belongs to the placeholder; live content is not touched. + assertSame(content, shownChild(view)) + } + + @Test + fun `an error view set after the block collapsed waits for the next outcome`() { + val view = attachedView() + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + view.setErrorView(View(activity)) + shadowOf(Looper.getMainLooper()).idle() + + // Deliberate: re-expanding a collapsed block under the host's fingers would shove the + // rest of the screen around. The next reload picks the error view up. + assertEquals(View.GONE, view.visibility) + assertNull(shownChild(view)) + } + + @Test + fun `shows the content when ready`() { + val view = attachedView() + val content = View(activity) + provider.readyView = content + provider.report(EmbeddedBlockState.Ready) + + assertSame(content, shownChild(view)) + } + + @Test + fun `keeps the content child through a detach and shows it again on re-attach`() { + val view = attachedView() + val content = View(activity) + provider.readyView = content + provider.report(EmbeddedBlockState.Ready) + val parent = view.parent as LinearLayout + + parent.removeView(view) + shadowOf(Looper.getMainLooper()).idle() + + // The content is this block's own — a pause keeps it, nothing to rebuild later. + assertSame(view, content.parent) + + parent.addView(view, 500, 300) + showWindow(view) + + // The provider replayed Ready on resume — the same content is on screen again. + assertSame(content, shownChild(view)) + } + + @Test + fun `a failure with no error view empties the frame`() { + val view = attachedView() + provider.readyView = View(activity) + provider.report(EmbeddedBlockState.Ready) + provider.report(EmbeddedBlockState.Failed) + + // The block collapses, so it has nothing left to draw — including the dead content. + assertNull(shownChild(view)) + } + + @Test + fun `a custom error view replaces the empty frame`() { + val custom = View(activity) + val view = blockView() + view.setErrorView(custom) + attach(view) + + provider.report(EmbeddedBlockState.Failed) + + assertSame(custom, shownChild(view)) + } + + @Test + fun `empty content clears the frame`() { + val view = attachedView() + provider.report(EmbeddedBlockState.Empty) + + // Nothing to show is not an error: no error view, just an empty transparent frame + // (which the block hides anyway). + assertNull(shownChild(view)) + } + + @Test + fun `ready content without a view falls back to the error state`() { + val view = attachedView() + provider.readyView = null + provider.report(EmbeddedBlockState.Ready) + shadowOf(Looper.getMainLooper()).idle() + + // Nothing to attach — the frame must not stay on the placeholder forever. + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `by default the block hides itself when there is nothing to show`() { + val view = attachedView() + assertEquals(View.VISIBLE, view.visibility) + + provider.report(EmbeddedBlockState.Empty) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `by default the block hides itself when the content fails`() { + val view = attachedView() + + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `a custom error view keeps the block visible on failure by default`() { + val custom = View(activity) + val view = blockView() + view.setErrorView(custom) + attach(view) + + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + // Setting an error view is a request to show the failure (the iOS semantics): + // no listener override needed — the default onFail keeps the block in place. + assertEquals(View.VISIBLE, view.visibility) + assertSame(custom, shownChild(view)) + } + + @Test + fun `a custom error view keeps the block visible on an empty place too`() { + val custom = View(activity) + val view = blockView() + view.setErrorView(custom) + attach(view) + + provider.report(EmbeddedBlockState.Empty) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(View.VISIBLE, view.visibility) + assertSame(custom, shownChild(view)) + } + + @Test + fun `a fresh loading shows a previously hidden block again`() { + val view = attachedView() + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(View.GONE, view.visibility) + + // A new session reload: the content goes back to Loading — the block must come back. + provider.report(EmbeddedBlockState.Loading) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(View.VISIBLE, view.visibility) + } + + @Test + fun `the callback runs after the block applied its behavior so the host wins`() { + val view = attachedView() + view.setListener(object : MindboxEmbeddedBlockListener { + override fun onFail(view: MindboxEmbeddedBlockView) { + // The block already hid itself — a host that wants the error visible anyway + // simply re-shows it here. + view.visibility = View.VISIBLE + } + }) + + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(View.VISIBLE, view.visibility) + } + + @Test + fun `delivers onLoad when the content arrives`() { + val view = attachedView() + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + provider.readyView = View(activity) + provider.report(EmbeddedBlockState.Ready) + shadowOf(Looper.getMainLooper()).idle() + + // Loading is not a public outcome — only the load lands in the listener. + assertEquals(listOf("load"), listener.events) + } + + @Test + fun `an empty place is reported to the host as a place without content`() { + val view = attachedView() + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + provider.report(EmbeddedBlockState.Empty) + shadowOf(Looper.getMainLooper()).idle() + + // The host asks one question — did the place get content? — and gets one answer either + // way; an empty place is a normal outcome, not a breakage. + assertEquals(listOf("fail"), listener.events) + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `late listener still receives the current state`() { + val view = attachedView() + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("fail"), listener.events) + } + + @Test + fun `a Kotlin host implements the interface and takes one callback of two`() { + // The interface bodies carry their own weight for Kotlin without any JVM-default compiler + // flag: the compiler fills the untouched callback in. Java is the one that needs the + // adapter, and this test is what keeps that difference from quietly becoming a regression. + val taken = mutableListOf() + val view = attachedView() + view.setListener( + object : MindboxEmbeddedBlockListener { + override fun onFail(view: MindboxEmbeddedBlockView) { + taken.add("fail") + } + }, + ) + + provider.report(EmbeddedBlockState.Empty) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("fail"), taken) + } + + @Test + fun `the adapter lets a host take one callback and ignore the other`() { + // What a Java host gets instead of JVM default methods: the interface members are abstract + // in bytecode, so overriding one of two is only possible through this class. + val taken = mutableListOf() + val view = attachedView() + view.setListener( + object : MindboxEmbeddedBlockListenerAdapter() { + override fun onFail(view: MindboxEmbeddedBlockView) { + taken.add("fail") + } + }, + ) + + provider.report(EmbeddedBlockState.Empty) + shadowOf(Looper.getMainLooper()).idle() + provider.readyView = View(activity) + provider.report(EmbeddedBlockState.Ready) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("fail"), taken) + } + + @Test + fun `re-registering the same listener does not replay the outcome`() { + val view = attachedView() + view.setListener(listener) + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + // A recycled row rebinds its listener on every pass. The host rebuilds its layout on the + // outcome, and rebuilding the layout rebinds the listener — replaying here spins that. + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("fail"), listener.events) + } + + @Test + fun `a different listener still receives the current outcome`() { + val view = attachedView() + view.setListener(listener) + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + val second = RecordingListener() + view.setListener(second) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("fail"), second.events) + } + + @Test + fun `dropping the listener stops the callbacks without touching the content`() { + val view = attachedView() + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + + view.setListener(null) + provider.readyView = View(activity) + provider.report(EmbeddedBlockState.Ready) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(listener.events.isEmpty()) + assertSame(provider.readyView, shownChild(view)) + } + + @Test + fun `the same state is not delivered twice`() { + val view = attachedView() + view.setListener(listener) + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("fail"), listener.events) + } + + @Test + fun `a changed state is delivered again`() { + val view = attachedView() + view.setListener(listener) + provider.readyView = View(activity) + provider.report(EmbeddedBlockState.Failed) + shadowOf(Looper.getMainLooper()).idle() + provider.report(EmbeddedBlockState.Ready) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf("fail", "load"), listener.events) + } + + @Test + fun `silent content times out into the error state`() { + val view = attachedView(timeout = Milliseconds(1_000L)) + view.setListener(listener) + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1_100L)) + + // Pause comes before the Failed state so the provider cannot resurrect the block. + assertEquals(1, provider.pauseCount) + assertEquals(listOf("fail"), listener.events) + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `content that resolved in time does not time out`() { + val view = attachedView(timeout = Milliseconds(1_000L)) + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + provider.readyView = View(activity) + provider.report(EmbeddedBlockState.Ready) + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2_000L)) + + assertEquals(0, provider.pauseCount) + assertEquals(listOf("load"), listener.events) + assertSame(provider.readyView, shownChild(view)) + } + + @Test + fun `detach cancels the pending timeout`() { + val view = attachedView(timeout = Milliseconds(1_000L)) + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + + (view.parent as LinearLayout).removeView(view) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2_000L)) + + // Only the detach pause, not a timeout pause on top of it — and no outcome delivered. + assertEquals(1, provider.pauseCount) + assertTrue(listener.events.isEmpty()) + } + + @Test + fun `claims the gesture from a horizontal host and releases it on a vertical move`() { + val view = attachedView() + provider.readyView = View(activity) + provider.report(EmbeddedBlockState.Ready) + + var disallowed: Boolean? = null + // LinearLayout propagates requestDisallowInterceptTouchEvent up; capture it via a spy parent. + val spy = object : LinearLayout(activity) { + override fun requestDisallowInterceptTouchEvent(disallow: Boolean) { + disallowed = disallow + super.requestDisallowInterceptTouchEvent(disallow) + } + } + (view.parent as LinearLayout).removeView(view) + spy.addView(view, 500, 300) + activity.setContentView(spy) + showWindow(view) + + fun motion(action: Int, x: Float, y: Float): MotionEvent = + MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), action, x, y, 0) + + view.dispatchTouchEvent(motion(MotionEvent.ACTION_DOWN, 100f, 10f)) + assertEquals(true, disallowed) + + view.dispatchTouchEvent(motion(MotionEvent.ACTION_MOVE, 105f, 300f)) + assertEquals(false, disallowed) + + view.dispatchTouchEvent(motion(MotionEvent.ACTION_UP, 105f, 300f)) + assertEquals(false, disallowed) + } + + @Test + fun `a loading block does not claim gestures`() { + // The placeholder is not interactive: the host must keep scrolling/paging freely. + val view = attachedView() + + var disallowed: Boolean? = null + val spy = object : LinearLayout(activity) { + override fun requestDisallowInterceptTouchEvent(disallow: Boolean) { + disallowed = disallow + super.requestDisallowInterceptTouchEvent(disallow) + } + } + (view.parent as LinearLayout).removeView(view) + spy.addView(view, 500, 300) + activity.setContentView(spy) + showWindow(view) + + val now = SystemClock.uptimeMillis() + view.dispatchTouchEvent(MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, 100f, 10f, 0)) + + assertNull(disallowed) + } + + @OptIn(InternalMindboxApi::class) + @Test + fun `release frees the content through the controller`() { + val view = attachedView() + (view.parent as LinearLayout).removeView(view) + shadowOf(Looper.getMainLooper()).idle() + + // The Compose wrapper calls this when the composable leaves the composition for good. + view.release() + + assertEquals(1, provider.releaseCount) + } + + @OptIn(InternalMindboxApi::class) + @Test + fun `a released block stops holding on to the host lifecycle`() { + val host = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = registry + } + host.registry.currentState = Lifecycle.State.RESUMED + val view = blockView() + val root = LinearLayout(activity).apply { addView(view, 500, 300) } + root.setViewTreeLifecycleOwner(host) + activity.setContentView(root) + showWindow(view) + assertEquals(1, host.registry.observerCount) + + view.release() + + // A Compose host releases a block every time it leaves the composition, and the observer + // holds the view: one that stays subscribed is kept alive until the whole screen dies. + assertEquals(0, host.registry.observerCount) + } + + @OptIn(InternalMindboxApi::class) + @Test + fun `a callback queued before the release never reaches the host`() { + val view = attachedView() + view.setListener(listener) + shadowOf(Looper.getMainLooper()).idle() + listener.events.clear() + + // The outcome is queued for the next main-loop pass; the host lets the block go before + // that pass runs. + provider.report(EmbeddedBlockState.Empty) + view.release() + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(listener.events.isEmpty()) + } + + @Test + fun `a blank place name asks for content for nobody`() { + mockkObject(MindboxEventManager) + try { + every { MindboxEventManager.embeddedPlaceRequested(any()) } just Runs + + // Built the way a host would with an unset XML attribute: a name made of spaces is + // no name, and it must not reach the in-app pipeline as a trigger either. + val view = MindboxEmbeddedBlockView(activity, " ") + attach(view) + + assertNull(view.placeSystemName) + verify(exactly = 0) { MindboxEventManager.embeddedPlaceRequested(any()) } + } finally { + unmockkObject(MindboxEventManager) + } + } + + @Test + fun `the destroyed host screen frees the content`() { + val host = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = registry + } + host.registry.currentState = Lifecycle.State.RESUMED + val view = blockView() + val root = LinearLayout(activity).apply { addView(view, 500, 300) } + root.setViewTreeLifecycleOwner(host) + activity.setContentView(root) + showWindow(view) + + host.registry.currentState = Lifecycle.State.DESTROYED + shadowOf(Looper.getMainLooper()).idle() + + // A host that never calls release() (a plain Activity or Fragment) must not leak a + // WebView per block; the view tree's lifecycle owner closes it. + assertEquals(1, provider.releaseCount) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/WindowVisibility.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/WindowVisibility.kt new file mode 100644 index 00000000..3b857572 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/WindowVisibility.kt @@ -0,0 +1,21 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.view.View +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter + +/** + * Robolectric leaves every window at GONE, and the embedded block starts its content only once + * the window is visible — so without this a unit-tested block would never start. On a device the + * framework dispatches the same call down the view tree right after the attach. + * + * The entry point is hidden from the SDK stubs, so it is reached through Robolectric's own + * helper for framework internals. + */ +internal fun dispatchWindowVisibility(view: View, visibility: Int) { + ReflectionHelpers.callInstanceMethod( + view, + "dispatchWindowVisibilityChanged", + ClassParameter.from(Int::class.javaPrimitiveType, visibility), + ) +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPageTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPageTest.kt new file mode 100644 index 00000000..33cac670 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPageTest.kt @@ -0,0 +1,516 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import android.content.Context +import android.net.Uri +import android.os.Looper +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class EmbeddedBlockWebViewPageTest { + + private val context = ApplicationProvider.getApplicationContext() + + private fun page( + source: EmbeddedBlockWebViewPage.Source = EmbeddedBlockWebViewPage.Source.Html(""), + domReadyFlag: String? = null, + ) = EmbeddedBlockWebViewPage(source, context, BRIDGE_NAME, domReadyFlag) + + private fun pageWithFlag(url: String = "https://example.com/stories.html") = + page(EmbeddedBlockWebViewPage.Source.Url(url), domReadyFlag = "storiesReady") + + private fun bridgeOf(page: EmbeddedBlockWebViewPage): Any? = + shadowOf(page.view as WebView).getJavascriptInterface(BRIDGE_NAME) + + private fun post(bridge: Any, json: String) { + bridge.javaClass.getDeclaredMethod("postMessage", String::class.java).invoke(bridge, json) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun mainFrameRequest(url: String): WebResourceRequest = mockk { + every { isForMainFrame } returns true + every { this@mockk.url } returns Uri.parse(url) + } + + private fun subFrameRequest(url: String): WebResourceRequest = mockk { + every { isForMainFrame } returns false + every { this@mockk.url } returns Uri.parse(url) + } + + @Test + fun `load with html source loads the html into the WebView`() { + val page = page(EmbeddedBlockWebViewPage.Source.Html("feed")) + page.load() + + assertEquals("feed", shadowOf(page.view as WebView).lastLoadDataWithBaseURL?.data) + } + + @Test + fun `load with url source loads the url`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("https://example.com/feed")) + page.load() + + assertEquals("https://example.com/feed", shadowOf(page.view as WebView).lastLoadedUrl) + } + + @Test + fun `a non-https page url is refused`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("http://example.com/feed")) + page.load() + + // Block content is loaded into the host's process: plain http would let anyone on the + // path inject the JS that talks to the bridge. + assertNull(shadowOf(page.view as WebView).lastLoadedUrl) + } + + @Test + fun `a url whose real host hides behind userinfo is refused`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("https://mobile-static.mindbox.ru@evil.example/feed")) + page.load() + + // Prefix-matching this url would pass; it actually loads evil.example. + assertNull(shadowOf(page.view as WebView).lastLoadedUrl) + } + + @Test + fun `load registers the js bridge under the block's bridge name`() { + val page = page() + page.load() + + assertNotNull(bridgeOf(page)) + } + + @Test + fun `a valid page message reaches onMessage on the main thread`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + post(bridgeOf(page)!!, """{"type":"ready","height":104}""") + + assertEquals( + listOf(TempEmbeddedBlockPageMessage.Ready(104.0)), + received, + ) + } + + @Test + fun `a page can say it has nothing to show`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + // Its own message on the wire, carrying no height: emptiness is a verdict, not a number. + post(bridgeOf(page)!!, """{"type":"empty"}""") + + assertEquals(listOf(TempEmbeddedBlockPageMessage.Empty), received) + } + + @Test + fun `an unparsable page message is dropped`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + post(bridgeOf(page)!!, "not a json") + + assertTrue(received.isEmpty()) + } + + @Test + fun `a paused page stops delivering messages`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + val bridge = bridgeOf(page)!! + + page.pause() + + // removeJavascriptInterface does not affect an already-loaded page, so the native side + // of the bridge must go deaf by itself while paused. + post(bridge, """{"type":"ready","height":104}""") + assertTrue(received.isEmpty()) + } + + @Test + fun `resume after pause reconnects the page without a reload`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + page.pause() + page.resume() + + post(bridgeOf(page)!!, """{"type":"ready","height":104}""") + + assertEquals(1, received.size) + } + + @Test + fun `page finish starts the dom readiness poll when a flag is configured`() { + val page = pageWithFlag() + page.load() + val webView = page.view as WebView + + shadowOf(webView).webViewClient.onPageFinished(webView, "https://example.com/stories.html") + + val probe = shadowOf(webView).lastEvaluatedJavascript + assertNotNull(probe) + assertTrue(probe.contains("storiesReady")) + } + + @Test + fun `without a flag the page is not polled at all`() { + val page = page(EmbeddedBlockWebViewPage.Source.Html("")) + page.load() + val webView = page.view as WebView + + shadowOf(webView).webViewClient.onPageFinished(webView, null) + + // The mock page answers over the bridge; polling it would spin every 200ms forever. + assertNull(shadowOf(webView).lastEvaluatedJavascript) + } + + @Test + fun `a positive dom probe result becomes a Ready message`() { + val page = pageWithFlag() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + page.onDomReadyProbeResult(probe = "probe", result = "96") + + assertEquals( + listOf(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 96.0)), + received, + ) + + // The flag stays "true" forever — the latch must not replay Ready on a re-poll. + page.onDomReadyProbeResult(probe = "probe", result = "96") + assertEquals(1, received.size) + } + + @Test + fun `a zero dom probe result keeps polling instead of reporting`() { + val page = pageWithFlag() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + page.onDomReadyProbeResult(probe = "probe", result = "0") + + assertTrue(received.isEmpty()) + } + + @Test + fun `a dom probe result after pause is ignored`() { + val page = pageWithFlag() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + page.pause() + + page.onDomReadyProbeResult(probe = "probe", result = "96") + + assertTrue(received.isEmpty()) + } + + @Test + fun `a main-frame load error is reported as a page error`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("https://example.com/feed")) + val errors = mutableListOf() + page.onPageError = { errors.add(it) } + page.load() + + val webView = page.view as WebView + // The pre-API-23 callback — the framework only ever calls it for the main frame. + @Suppress("DEPRECATION") + shadowOf(webView).webViewClient.onReceivedError( + webView, + -2, + "net::ERR_NAME_NOT_RESOLVED", + "https://example.com/feed", + ) + + assertEquals(1, errors.size) + assertTrue(errors.single().contains("-2")) + } + + @Test + fun `a sub-frame load error does not fail the block`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("https://example.com/feed")) + val errors = mutableListOf() + page.onPageError = { errors.add(it) } + page.load() + + val webView = page.view as WebView + shadowOf(webView).webViewClient.onReceivedError( + webView, + subFrameRequest("https://cdn.example.com/pixel.gif"), + mockk(relaxed = true), + ) + + // A tracking pixel or a lazy iframe failing is not the block failing. + assertTrue(errors.isEmpty()) + } + + @Test + fun `navigation away from the block page is blocked`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("https://example.com/feed")) + page.load() + val webView = page.view as WebView + + val handled = shadowOf(webView).webViewClient.shouldOverrideUrlLoading( + webView, + mainFrameRequest("https://evil.example/phish"), + ) + + // A block is a piece of the host's screen, not a browser: it must never navigate. + assertTrue(handled) + } + + @Test + fun `navigation is blocked on the pre-API-24 callback too`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("https://example.com/feed")) + page.load() + val webView = page.view as WebView + + @Suppress("DEPRECATION") + val handled = shadowOf(webView).webViewClient.shouldOverrideUrlLoading( + webView, + "https://evil.example/phish", + ) + + // API 21-23 call this overload instead, and its default *allows* the navigation. + assertTrue(handled) + } + + @Test + fun `an inner frame is allowed to load`() { + val page = page(EmbeddedBlockWebViewPage.Source.Url("https://example.com/feed")) + page.load() + val webView = page.view as WebView + + val handled = shadowOf(webView).webViewClient.shouldOverrideUrlLoading( + webView, + subFrameRequest("about:blank"), + ) + + // Dynamic iframes legitimately start at about:blank — blocking them breaks real pages. + assertFalse(handled) + } + + @Test + fun `release destroys the WebView for good`() { + val page = page() + page.load() + + page.release() + + assertTrue(shadowOf(page.view as WebView).wasDestroyCalled()) + } + + @Test + fun `a released page ignores further lifecycle calls`() { + val page = page() + page.load() + page.release() + + // A destroyed WebView must never be touched again — every call is a no-op, no crash. + page.load() + page.resume() + page.pause() + } + + @Test + fun `a released page delivers nothing`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + val bridge = bridgeOf(page)!! + + page.release() + post(bridge, """{"type":"ready","height":104}""") + + // The callbacks point at a container that is already gone. + assertTrue(received.isEmpty()) + } + + @Test + fun `oversized bridge message is dropped`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + val huge = """{"type":"ready","height":""" + "1".repeat(20_000) + "}" + post(bridgeOf(page)!!, huge) + + assertTrue(received.isEmpty()) + } + + @Test + fun `a page flooding the bridge is throttled`() { + val page = page() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + val bridge = bridgeOf(page)!! + + repeat(100) { post(bridge, """{"type":"heightChanged","height":120}""") } + + // Every accepted message posts a main-looper runnable: an unthrottled loop in the page + // would ANR the whole host app. + assertTrue("expected throttling, got ${received.size}", received.size < 100) + } + + @Test + fun `repeated load registers the bridge once`() { + val page = page() + page.load() + page.load() + + assertNotNull(bridgeOf(page)) + } + + @Test + fun `a message outside the common protocol goes to the mechanic raw`() { + val page = page() + val common = mutableListOf() + val mechanic = mutableListOf() + page.onMessage = { common.add(it) } + page.onMechanicMessage = { mechanic.add(it) } + page.load() + + // The stories dialect — the generic layer must not understand it, only route it. + post(bridgeOf(page)!!, """{"type":"storyTap","storyId":"sales"}""") + + assertTrue(common.isEmpty()) + assertEquals("sales", mechanic.single().optString("storyId")) + } + + @Test + fun `a common protocol message does not reach the mechanic`() { + val page = page() + val common = mutableListOf() + val mechanic = mutableListOf() + page.onMessage = { common.add(it) } + page.onMechanicMessage = { mechanic.add(it) } + page.load() + + post(bridgeOf(page)!!, """{"type":"ready","height":104}""") + + assertTrue(mechanic.isEmpty()) + assertEquals(1, common.size) + } + + @Test + fun `a mechanic message with nobody to take it is dropped quietly`() { + val page = page() + page.load() + + post(bridgeOf(page)!!, """{"type":"storyTap","storyId":"sales"}""") + } + + @Test + fun `a bridge ready resolves the page and ends the dom poll`() { + // A dual-protocol setup: the flag is configured, but the page answers over the bridge + // (the mock page). The bridge answer must latch the poll, or the probe would spin + // every 200ms for as long as the block is on screen. + val page = page(EmbeddedBlockWebViewPage.Source.Html(""), domReadyFlag = "storiesReady") + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + post(bridgeOf(page)!!, """{"type":"ready","height":104}""") + + // A late probe result must neither replay Ready nor reschedule the poll. + page.onDomReadyProbeResult(probe = "probe", result = "96") + assertEquals(1, received.size) + } + + @Test + fun `a page error ends the dom poll`() { + val page = pageWithFlag() + val received = mutableListOf() + page.onMessage = { received.add(it) } + page.load() + + val webView = page.view as WebView + @Suppress("DEPRECATION") + shadowOf(webView).webViewClient.onReceivedError( + webView, + -2, + "net::ERR_FAILED", + "https://example.com/stories.html", + ) + + // The outcome is known — polling a broken page for readiness is pointless. + page.onDomReadyProbeResult(probe = "probe", result = "96") + assertTrue(received.isEmpty()) + } + + @Test + fun `release after a renderer crash still destroys the WebView`() { + val page = page() + page.load() + val webView = page.view as WebView + + shadowOf(webView).webViewClient.onRenderProcessGone( + webView, + object : android.webkit.RenderProcessGoneDetail() { + override fun didCrash() = true + + override fun rendererPriorityAtExit() = 0 + }, + ) + page.release() + + // The renderer's death must not block the platform contract: a crashed WebView is + // still detached and destroyed, not left to the GC. + assertTrue(shadowOf(webView).wasDestroyCalled()) + } + + @Test + fun `a dead renderer is reported and taken off the screen`() { + val page = page() + val errors = mutableListOf() + page.onPageError = { errors.add(it) } + page.load() + val webView = page.view as WebView + + val handled = shadowOf(webView).webViewClient.onRenderProcessGone( + webView, + object : android.webkit.RenderProcessGoneDetail() { + override fun didCrash() = true + + override fun rendererPriorityAtExit() = 0 + }, + ) + + // Returning false here is what crashes the HOST app on API 26+. + assertTrue(handled) + assertEquals(1, errors.size) + } + + private companion object { + private const val BRIDGE_NAME = "testBridge" + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProviderTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProviderTest.kt new file mode 100644 index 00000000..e2e1c29d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProviderTest.kt @@ -0,0 +1,269 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import android.view.View +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.embedded.EmbeddedBlockState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EmbeddedBlockWebViewProviderTest { + + private class FakePage : EmbeddedBlockPage { + override val view: View = View(ApplicationProvider.getApplicationContext()) + override var onMessage: ((TempEmbeddedBlockPageMessage) -> Unit)? = null + override var onMechanicMessage: ((org.json.JSONObject) -> Unit)? = null + override var onPageError: ((String) -> Unit)? = null + var loadCount = 0 + var pauseCount = 0 + var resumeCount = 0 + var releaseCount = 0 + + override fun load() { + loadCount++ + } + + override fun pause() { + pauseCount++ + } + + override fun resume() { + resumeCount++ + } + + override fun release() { + releaseCount++ + } + + fun send(message: TempEmbeddedBlockPageMessage) { + onMessage?.invoke(message) + } + + fun fail(description: String) { + onPageError?.invoke(description) + } + } + + private lateinit var page: FakePage + private lateinit var provider: EmbeddedBlockWebViewProvider + private val states = mutableListOf() + + @Before + fun setUp() { + page = FakePage() + provider = EmbeddedBlockWebViewProvider(page) + states.clear() + provider.onStateChange = { states.add(it) } + } + + @Test + fun `start reports loading and loads the page`() { + provider.start() + + assertEquals(listOf(EmbeddedBlockState.Loading), states) + assertEquals(1, page.loadCount) + } + + @Test + fun `ready message with a plausible height becomes the Ready state`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + + // The height is only validated (zero → Failed, implausible → Failed), never carried: + // the host owns the block size. + assertEquals(EmbeddedBlockState.Ready, states.last()) + assertNotNull(provider.contentView) + } + + @Test + fun `heightChanged is not a second way to say ready`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.HeightChanged(heightCssPx = 150.0)) + + // The host owns the height, so this message carries nothing the native side can act on. + // Showing a block on it would let a page skip the readiness handshake entirely. + assertEquals(EmbeddedBlockState.Loading, states.last()) + assertNull(provider.contentView) + } + + @Test + fun `a relayout to zero does not collapse a block the user is looking at`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + page.send(TempEmbeddedBlockPageMessage.HeightChanged(heightCssPx = 0.0)) + + // A page measuring itself mid-animation reports zero and recovers a frame later; pulling + // the block out of the host layout for that would be a visible jump for nothing. + assertEquals(EmbeddedBlockState.Ready, states.last()) + assertNotNull(provider.contentView) + } + + @Test + fun `a page with nothing to show says so and empties the block`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Empty) + + // The page worked, its targeting just matched nothing — the empty state, not a failure. + assertEquals(EmbeddedBlockState.Empty, states.last()) + assertNull(provider.contentView) + // The buried page is silenced: invisible content must not keep running JS. + assertEquals(1, page.pauseCount) + } + + @Test + fun `a page that empties itself after being ready collapses the block`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + page.send(TempEmbeddedBlockPageMessage.Empty) + + // Content can disappear live (every story watched, targeting re-evaluated). + assertEquals(EmbeddedBlockState.Empty, states.last()) + assertNull(provider.contentView) + } + + @Test + fun `a ready at zero height is a broken page, not an empty one`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 0.0)) + + // The page announced it rendered and rendered nothing. Emptiness has its own message, so + // this is a contradiction — and a block must not pass a contradiction off as a normal + // outcome. + assertEquals(EmbeddedBlockState.Failed, states.last()) + assertNull(provider.contentView) + assertEquals(1, page.pauseCount) + } + + @Test + fun `a negative height is broken the same way`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = -10.0)) + + assertEquals(EmbeddedBlockState.Failed, states.last()) + } + + @Test + fun `a failed page is silenced`() { + provider.start() + + page.fail("Page load error") + + assertEquals(EmbeddedBlockState.Failed, states.last()) + assertEquals(1, page.pauseCount) + } + + @Test + fun `implausible height means a broken page and fails the block`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 1.0e9)) + + // Honoring it would hand one JS message the power to blow up the host's measure pass. + assertEquals(EmbeddedBlockState.Failed, states.last()) + assertNull(provider.contentView) + assertEquals(1, page.pauseCount) + } + + @Test + fun `contentView is hidden until the page is ready`() { + provider.start() + + assertNull(provider.contentView) + } + + @Test + fun `pause quiets the page and silences it until the next start`() { + provider.start() + provider.pause() + states.clear() + + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + + assertTrue(states.isEmpty()) + assertEquals(1, page.pauseCount) + } + + @Test + fun `start after pause resumes the page and replays the current state`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + provider.pause() + states.clear() + + provider.start() + + // A pause is not a teardown: the page is resumed, never reloaded, and the container + // immediately learns where the content stands. + assertEquals(1, page.loadCount) + assertEquals(1, page.resumeCount) + assertTrue(states.single() is EmbeddedBlockState.Ready) + assertNotNull(provider.contentView) + } + + @Test + fun `messages are accepted again after a resume`() { + provider.start() + provider.pause() + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + + assertTrue(states.last() is EmbeddedBlockState.Ready) + } + + @Test + fun `a page error fails the block right away`() { + provider.start() + + page.fail("Page load error -2: net::ERR_NAME_NOT_RESOLVED") + + // No waiting out the container's timeout: a broken main frame is terminal. + assertEquals(EmbeddedBlockState.Failed, states.last()) + assertNull(provider.contentView) + } + + @Test + fun `a page error while paused is latched and the next start replays Failed`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + provider.pause() + states.clear() + + // The system is free to kill the renderer of a backgrounded WebView; the paused + // container is not notified right away… + page.fail("renderer gone while paused") + assertTrue(states.isEmpty()) + + provider.start() + + // …but the next start must replay Failed, not the stale Ready over a dead page. + assertEquals(EmbeddedBlockState.Failed, states.last()) + assertNull(provider.contentView) + } + + @Test + fun `release tears the page down for good`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + + provider.release() + + assertEquals(1, page.releaseCount) + assertNull(provider.contentView) + } + + @Test + fun `a released provider ignores the page it no longer owns`() { + provider.start() + provider.release() + states.clear() + + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + + assertTrue(states.isEmpty()) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerTest.kt index 78fd7ef0..a8cb2505 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppEventManagerTest.kt @@ -52,4 +52,15 @@ internal class InAppEventManagerTest { fun `validate system event`() { assertFalse(inAppEventManager.isValidInAppEvent(InAppEventType.OrdinalEvent(EventType.AppInstalledWithoutCustomer))) } + + @Test + fun `validate embedded place request`() { + // A block appearing on screen has to reach the pipeline: what fills the place is an + // in-app picked by targeting, same as for any other trigger. + assertTrue( + inAppEventManager.isValidInAppEvent( + InAppEventType.EmbeddedPlaceRequested(placeSystemName = "main-screen-top"), + ), + ) + } } diff --git a/settings.gradle b/settings.gradle index 0ac91206..b613ffb6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,6 +7,7 @@ include ':mindbox-firebase-starter' include ':mindbox-huawei-starter' include ':mindbox-rustore-starter' include ':mindbox-sdk-starter-core' +include ':mindbox-embedded-compose' include ':mindbox-common' rootProject.name = "AndroidSdk"