From 82cc4eeb3d5c7a4f523dd8f6aeb135cb8975f1e0 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 10 Aug 2026 13:17:36 +0300 Subject: [PATCH 1/8] MOBILE-324: Add the temporary embedded-block contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stands in for the backend contract (MOBILE-344) until it lands: the inlineBlocks config section, the block page bridge name and the page message protocol. Everything here is prefixed Temp on purpose — it is replaced wholesale once the real contract arrives and the block moves to the shared JS bridge, so nothing else should grow a dependency on these shapes. --- .../embedded/TempEmbeddedBlocksConfig.kt | 58 +++++++++++++++++++ .../webview/TempEmbeddedBlockPageContract.kt | 10 ++++ .../webview/TempEmbeddedBlockPageMessage.kt | 36 ++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/TempEmbeddedBlocksConfig.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageContract.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageMessage.kt 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..124ea1a0 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/TempEmbeddedBlocksConfig.kt @@ -0,0 +1,58 @@ +package cloud.mindbox.mobile_sdk.embedded + +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? { + 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/webview/TempEmbeddedBlockPageContract.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageContract.kt new file mode 100644 index 00000000..eecf7eca --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageContract.kt @@ -0,0 +1,10 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +// Shared with deployed pages and the iOS SDK: renaming either value breaks every published page, +// so it has to happen together with the web team. +internal object TempEmbeddedBlockPageContract { + + const val BRIDGE_NAME: String = "mindboxStoriesFeed" + + 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..c376b920 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/TempEmbeddedBlockPageMessage.kt @@ -0,0 +1,36 @@ +package cloud.mindbox.mobile_sdk.embedded.webview + +import org.json.JSONObject + +internal sealed class TempEmbeddedBlockPageMessage { + + data class Ready(val heightCssPx: Double) : TempEmbeddedBlockPageMessage() + + data class HeightChanged(val heightCssPx: Double) : TempEmbeddedBlockPageMessage() + + companion object { + + 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) } + 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 + } + } +} From 496880a958d3fb16806cb02c31cfe1105f815a84 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 10 Aug 2026 13:17:56 +0300 Subject: [PATCH 2/8] MOBILE-324: Add the debug-only mock stand for embedded blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injects a hardcoded inlineBlocks section into the fetched mobile config and serves a mock feed page with switchable scenarios (success, empty, error, slow), so the block can be driven end to end before the backend sends anything. MUST NOT REACH develop. The injection is guarded by BuildConfig.DEBUG, so a release build never substitutes the staging page into a host app's config — verified on a release build. --- .../TempEmbeddedBlocksMockConfigSection.kt | 40 ++++ .../mock/TempMindboxStoriesFeedMock.kt | 33 +++ .../embedded/mock/TempStoriesFeedMockPage.kt | 217 ++++++++++++++++++ .../MobileConfigRepositoryImpl.kt | 10 +- 4 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlocksMockConfigSection.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempMindboxStoriesFeedMock.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempStoriesFeedMockPage.kt 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..04bf2b9d --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlocksMockConfigSection.kt @@ -0,0 +1,40 @@ +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 + + 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..1d062840 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempMindboxStoriesFeedMock.kt @@ -0,0 +1,33 @@ +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 reports zero height — 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, + } + + 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..7dce9151 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempStoriesFeedMockPage.kt @@ -0,0 +1,217 @@ +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 = + PAGE_TEMPLATE.replace("__SCENARIO__", scenario.name) + + private val PAGE_TEMPLATE = """ + + + + + + + +
+
MOCK
+ + + + """.trimIndent() +} 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..a03a80f3 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,7 @@ 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.TempEmbeddedBlocksMockConfigSection import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull @@ -71,8 +72,13 @@ 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. + MindboxPreferences.inAppConfig = TempEmbeddedBlocksMockConfigSection.inject( + gatewayManager.fetchMobileConfig( + configuration = configuration + ) ) MindboxPreferences.inAppConfigUpdatedTime = System.currentTimeMillis() } From 7f20da78550bfb84fceb91e8b124292ce6ef2ce0 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 10 Aug 2026 13:18:41 +0300 Subject: [PATCH 3/8] MOBILE-324: Add embedded blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host marks a place by placeSystemName and gives it a fixed height; the mobile config decides what goes into it. MindboxEmbeddedBlockView is the whole public surface, with a Compose wrapper in the new mindbox-embedded-compose module. The block owns its behavior: visible while loading and while showing content, GONE when the place ends up without content unless the host set an error view. The listener only observes. Inside, the controller decides when to resolve, when to drop and when to give the page its 7s budget; the factory turns the config into one of three outcomes, telling apart 'nothing here' from 'config has not arrived yet' so a slow start-up never collapses a block; the webview layer keeps the untrusted page contained — https only, no navigation, a rate-limited bridge, and a dead renderer that no longer takes the host app down. Shared SDK code touched: - SessionStorageManager: listeners can now unsubscribe, and the list is copy-on-write since blocks subscribe from the main thread while the keepalive timer notifies from another. - MindboxEventManager/Event: the embeddedPlaceRequested signal. - InAppEventManagerImpl: that signal reaches the in-app pipeline, so a place can be filled by targeting once embedded in-apps exist. - Constants: the 7s WebView readiness budget, shared with the in-app holder that already used the same number. --- .editorconfig | 5 +- build.gradle | 1 + gradle/libs.versions.toml | 8 + mindbox-embedded-compose/build.gradle | 39 +++ mindbox-embedded-compose/gradle.properties | 4 + mindbox-embedded-compose/proguard-rules.pro | 1 + .../embedded/compose/MindboxEmbeddedBlock.kt | 103 ++++++ modulesCommon.gradle | 4 + sdk/build.gradle | 2 + sdk/consumer-rules.pro | 4 + .../EmbeddedBlockContentController.kt | 197 +++++++++++ .../embedded/EmbeddedBlockContentFactory.kt | 71 ++++ .../embedded/EmbeddedBlockDefaultViews.kt | 64 ++++ .../mobile_sdk/embedded/EmbeddedBlockState.kt | 15 + .../embedded/EmbeddedContentProvider.kt | 16 + .../embedded/EmbeddedContentResolution.kt | 10 + .../embedded/MindboxEmbeddedBlockListener.kt | 35 ++ .../embedded/MindboxEmbeddedBlockView.kt | 325 ++++++++++++++++++ .../embedded/webview/EmbeddedBlockPage.kt | 23 ++ .../webview/EmbeddedBlockWebViewPage.kt | 316 +++++++++++++++++ .../webview/EmbeddedBlockWebViewProvider.kt | 103 ++++++ .../data/managers/SessionStorageManager.kt | 7 +- .../inapp/domain/InAppEventManagerImpl.kt | 4 + .../view/WebViewInappViewHolder.kt | 6 +- .../managers/MindboxEventManager.kt | 9 + .../cloud/mindbox/mobile_sdk/models/Event.kt | 6 + .../mindbox/mobile_sdk/utils/Constants.kt | 6 + sdk/src/main/res/values-night/colors.xml | 5 + sdk/src/main/res/values/attrs.xml | 6 + sdk/src/main/res/values/colors.xml | 2 + settings.gradle | 1 + 31 files changed, 1393 insertions(+), 5 deletions(-) create mode 100644 mindbox-embedded-compose/build.gradle create mode 100644 mindbox-embedded-compose/gradle.properties create mode 100644 mindbox-embedded-compose/proguard-rules.pro create mode 100644 mindbox-embedded-compose/src/main/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlock.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockDefaultViews.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockState.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentProvider.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedContentResolution.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockPage.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPage.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProvider.kt create mode 100644 sdk/src/main/res/values-night/colors.xml create mode 100644 sdk/src/main/res/values/attrs.xml 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..7adda643 --- /dev/null +++ b/mindbox-embedded-compose/src/main/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlock.kt @@ -0,0 +1,103 @@ +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.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) + + key(placeSystemName) { + var isCollapsed by remember { mutableStateOf(false) } + + AndroidView( + modifier = (if (isCollapsed) Modifier.height(0.dp).then(modifier) else modifier).fillMaxWidth(), + factory = { context -> + MindboxEmbeddedBlockView(context, placeSystemName).apply { + setVisibilityObserver { isVisible -> isCollapsed = !isVisible } + setListener( + object : MindboxEmbeddedBlockListener { + override fun onLoad(view: MindboxEmbeddedBlockView) { + currentOnLoad() + } + + override fun onFail(view: MindboxEmbeddedBlockView) { + currentOnFail() + } + }, + ) + if (placeholder != null) { + setPlaceholderView( + ComposeView(context).apply { setContent { currentPlaceholder?.invoke() } }, + ) + } + if (error != null) { + setErrorView( + ComposeView(context).apply { setContent { currentError?.invoke() } }, + ) + } + } + }, + onRelease = { view -> view.release() }, + ) + } +} diff --git a/modulesCommon.gradle b/modulesCommon.gradle index 9e621e5e..9944f1d7 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -46,6 +46,10 @@ android { kotlinOptions { jvmTarget = '11' + // Interface default bodies must be real JVM default methods: a Java host implementing a + // listener overrides only what it needs. all-compatibility keeps DefaultImpls for + // binary compatibility with already-published code. + freeCompilerArgs += ['-Xjvm-default=all-compatibility'] } kotlin { 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..05234356 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt @@ -0,0 +1,35 @@ +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. + * + * Callbacks arrive on the main thread, each outcome once. A listener registered after the block + * already loaded or failed still gets the current outcome. + */ +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 recovers on + * the next attach or when a new session brings a fresh config. + * + * @param view The block left without content. + */ + public 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..f0b8dc1d --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt @@ -0,0 +1,325 @@ +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) }, + placeSystemName = placeSystemName, + ), +) : 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?.takeIf { it.isNotBlank() } + 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") + observedLifecycle?.removeObserver(this) + observedLifecycle = null + mainHandler.removeCallbacksAndMessages(null) + isDeliveryScheduled = false + 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?) { + this.listener = listener ?: DefaultListener + if (listener == null) return + 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") + loggingRunCatching { contentController.release() } + } + + 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 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/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..5a722bb0 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPage.kt @@ -0,0 +1,316 @@ +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.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.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 + } + + 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..222f0fdf --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProvider.kt @@ -0,0 +1,103 @@ +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 -> applyHeight(message.heightCssPx) + } + } + + private fun applyHeight(heightCssPx: Double) { + // Zero height means the page worked and its targeting matched nothing — empty, not broken. + if (heightCssPx <= 0) { + mindboxLogI("[EmbeddedBlock] Block page reported zero height — nothing to show") + isReady = false + report(EmbeddedBlockState.Empty) + 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/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/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/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" From ed34fe11ca03fe6deeb45988e40378beb4832651 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 10 Aug 2026 13:18:59 +0300 Subject: [PATCH 4/8] MOBILE-324: Cover embedded blocks with tests 128 tests over the block: the controller's decisions (resolve once, reload on a new session, wait for a config that has not arrived, budget only the page), the view's states and callbacks, the provider's reading of page heights, and the page hardening (https, navigation, bridge rate limit, dead renderer). Robolectric never raises a window out of GONE and the block starts its content only on a visible window, so the tests dispatch that callback themselves through Robolectric's own helper. --- .../compose/MindboxEmbeddedBlockTest.kt | 136 ++++ .../embedded/compose/WindowVisibility.kt | 21 + .../EmbeddedBlockContentControllerTest.kt | 561 +++++++++++++++++ .../EmbeddedBlockContentFactoryTest.kt | 77 +++ .../MindboxEmbeddedBlockViewLookupTest.kt | 128 ++++ .../embedded/MindboxEmbeddedBlockViewTest.kt | 581 ++++++++++++++++++ .../mobile_sdk/embedded/WindowVisibility.kt | 21 + .../webview/EmbeddedBlockWebViewPageTest.kt | 503 +++++++++++++++ .../EmbeddedBlockWebViewProviderTest.kt | 242 ++++++++ .../inapp/domain/InAppEventManagerTest.kt | 11 + 10 files changed, 2281 insertions(+) create mode 100644 mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlockTest.kt create mode 100644 mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/WindowVisibility.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/WindowVisibility.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPageTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProviderTest.kt 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..ba30da0d --- /dev/null +++ b/mindbox-embedded-compose/src/test/java/cloud/mindbox/mobile_sdk/embedded/compose/MindboxEmbeddedBlockTest.kt @@ -0,0 +1,136 @@ +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 `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/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..929b94e5 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt @@ -0,0 +1,581 @@ +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.models.Milliseconds +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 `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) + } + + @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..63ec502f --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewPageTest.kt @@ -0,0 +1,503 @@ +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 `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..acc95779 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewProviderTest.kt @@ -0,0 +1,242 @@ +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 → Empty, implausible → Failed), never carried: + // the host owns the block size. + assertEquals(EmbeddedBlockState.Ready, states.last()) + assertNotNull(provider.contentView) + } + + @Test + fun `heightChanged after ready keeps the block Ready`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) + page.send(TempEmbeddedBlockPageMessage.HeightChanged(heightCssPx = 150.0)) + + assertEquals(EmbeddedBlockState.Ready, states.last()) + } + + @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.HeightChanged(heightCssPx = 0.0)) + + // Content can disappear live (every story watched, targeting re-evaluated). + assertEquals(EmbeddedBlockState.Empty, states.last()) + assertNull(provider.contentView) + } + + @Test + fun `zero height means nothing to show and empties the block`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 0.0)) + + // 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 negative height is treated as nothing to show`() { + provider.start() + page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = -10.0)) + + assertEquals(EmbeddedBlockState.Empty, 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"), + ), + ) + } } From a7e7383c0da53ae84b1b54d24b0d9a820d5ab862 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 10 Aug 2026 14:35:04 +0300 Subject: [PATCH 5/8] MOBILE-324: follow review --- .../embedded/compose/MindboxEmbeddedBlock.kt | 32 ++++++---- .../compose/MindboxEmbeddedBlockTest.kt | 26 ++++++++ .../embedded/MindboxEmbeddedBlockListener.kt | 11 +++- .../embedded/MindboxEmbeddedBlockView.kt | 22 +++++-- .../webview/EmbeddedBlockWebViewPage.kt | 3 + .../embedded/MindboxEmbeddedBlockViewTest.kt | 64 +++++++++++++++++++ 6 files changed, 137 insertions(+), 21 deletions(-) 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 index 7adda643..3e261f53 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -66,13 +67,26 @@ public fun MindboxEmbeddedBlock( 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 = { context -> - MindboxEmbeddedBlockView(context, placeSystemName).apply { + factory = { viewContext -> + MindboxEmbeddedBlockView(viewContext, placeSystemName).apply { setVisibilityObserver { isVisible -> isCollapsed = !isVisible } setListener( object : MindboxEmbeddedBlockListener { @@ -85,18 +99,12 @@ public fun MindboxEmbeddedBlock( } }, ) - if (placeholder != null) { - setPlaceholderView( - ComposeView(context).apply { setContent { currentPlaceholder?.invoke() } }, - ) - } - if (error != null) { - setErrorView( - ComposeView(context).apply { setContent { currentError?.invoke() } }, - ) - } } }, + 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 index ba30da0d..1b9ea9e0 100644 --- 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 @@ -100,6 +100,32 @@ class MindboxEmbeddedBlockTest { 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 { 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 index 05234356..f6b35142 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt @@ -26,8 +26,15 @@ public interface MindboxEmbeddedBlockListener { * 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 recovers on - * the next attach or when a new session brings a fresh config. + * 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. */ 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 index f0b8dc1d..0f36c3ed 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt @@ -51,8 +51,8 @@ public class MindboxEmbeddedBlockView internal constructor( attrs: AttributeSet?, placeSystemName: String?, private val contentController: EmbeddedBlockContentController = EmbeddedBlockContentController( - resolveFactory = { EmbeddedBlockContentFactory.resolve(context, placeSystemName) }, - placeSystemName = placeSystemName, + resolveFactory = { EmbeddedBlockContentFactory.resolve(context, placeSystemName.orNullIfBlank()) }, + placeSystemName = placeSystemName.orNullIfBlank(), ), ) : FrameLayout(context, attrs) { @@ -67,7 +67,7 @@ public class MindboxEmbeddedBlockView internal constructor( placeSystemName: String, ) : this(context, null, placeSystemName) - public val placeSystemName: String? = placeSystemName?.takeIf { it.isNotBlank() } + public val placeSystemName: String? = placeSystemName.orNullIfBlank() private var listener: MindboxEmbeddedBlockListener = DefaultListener private var visibilityObserver: ((Boolean) -> Unit)? = null private var placeholderView: View? = null @@ -92,10 +92,7 @@ public class MindboxEmbeddedBlockView internal constructor( private val hostDestroyObserver = object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { mindboxLogI("[EmbeddedBlock] Host screen destroyed, freeing content") - observedLifecycle?.removeObserver(this) - observedLifecycle = null - mainHandler.removeCallbacksAndMessages(null) - isDeliveryScheduled = false + detachFromHost() loggingRunCatching { contentController.release() } } } @@ -196,9 +193,18 @@ public class MindboxEmbeddedBlockView internal constructor( @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 @@ -314,6 +320,8 @@ public class MindboxEmbeddedBlockView internal constructor( } } +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) 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 index 5a722bb0..499b3832 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -17,6 +18,7 @@ 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 @@ -97,6 +99,7 @@ internal class EmbeddedBlockWebViewPage( return true } + @RequiresApi(Build.VERSION_CODES.M) override fun onReceivedError( view: WebView, request: WebResourceRequest, 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 index 929b94e5..0863beb8 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt @@ -11,7 +11,14 @@ 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 @@ -558,6 +565,63 @@ class MindboxEmbeddedBlockViewTest { 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 { From 891b9a29ff84ff9c3145f0711dd370ec96482340 Mon Sep 17 00:00:00 2001 From: sozinov Date: Tue, 11 Aug 2026 15:15:21 +0300 Subject: [PATCH 6/8] MOBILE-324: follow review --- .../embedded/MindboxEmbeddedBlockView.kt | 9 +++- .../mock/TempMindboxStoriesFeedMock.kt | 2 +- .../embedded/mock/TempStoriesFeedMockPage.kt | 19 ++++---- .../webview/EmbeddedBlockWebViewProvider.kt | 18 +++++-- .../webview/TempEmbeddedBlockPageContract.kt | 10 ++-- .../webview/TempEmbeddedBlockPageMessage.kt | 8 ++++ .../embedded/MindboxEmbeddedBlockViewTest.kt | 29 ++++++++++++ .../webview/EmbeddedBlockWebViewPageTest.kt | 13 +++++ .../EmbeddedBlockWebViewProviderTest.kt | 47 +++++++++++++++---- 9 files changed, 126 insertions(+), 29 deletions(-) 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 index 0f36c3ed..7e5cad38 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt @@ -116,8 +116,15 @@ public class MindboxEmbeddedBlockView internal constructor( } public fun setListener(listener: MindboxEmbeddedBlockListener?) { - this.listener = listener ?: DefaultListener + 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() } 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 index 1d062840..d7cd2e47 100644 --- 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 @@ -19,7 +19,7 @@ public object TempMindboxStoriesFeedMock { /** The feed renders and reports its height — the happy path. */ SUCCESS, - /** Targeting matched nothing: the page reports zero height — the empty state (hidden by default). */ + /** 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. */ 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 index 7dce9151..8126f7d9 100644 --- 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 @@ -109,13 +109,13 @@ internal object TempStoriesFeedMockPage { function post(payload) { var json = JSON.stringify(payload); - if (window.mindboxStoriesFeed && window.mindboxStoriesFeed.postMessage) { - window.mindboxStoriesFeed.postMessage(json); + if (window.mindboxEmbeddedBlock && window.mindboxEmbeddedBlock.postMessage) { + window.mindboxEmbeddedBlock.postMessage(json); return; } var handlers = window.webkit && window.webkit.messageHandlers; - if (handlers && handlers.mindboxStoriesFeed) { - handlers.mindboxStoriesFeed.postMessage(json); + if (handlers && handlers.mindboxEmbeddedBlock) { + handlers.mindboxEmbeddedBlock.postMessage(json); } } @@ -168,8 +168,6 @@ internal object TempStoriesFeedMockPage { function reportHeightChange() { var height = trayHeight(); - // Zero height is the "nothing to show" verdict; the observer never issues it, so an - // intermediate relayout cannot collapse a feed that is already shown. if (height > 0) { post({ type: "heightChanged", height: height }); } @@ -198,10 +196,11 @@ internal object TempStoriesFeedMockPage { return; } if (SCENARIO === "EMPTY") { - // Targeting matched nothing: zero height is the explicit "nothing to show" verdict. - // Delayed like real life — the emptiness is only known after the backend answers, - // so the block shows its placeholder for a couple of seconds first. - setTimeout(function () { post({ type: "ready", height: 0 }); }, EMPTY_DELAY_MS); + // Targeting matched nothing. Said with its own message, never as a ready of zero + // height: that one means the page believes it rendered and did not. Delayed like real + // life — the emptiness is only known after the backend answers, so the block shows its + // placeholder for a couple of seconds first. + setTimeout(function () { post({ type: "empty" }); }, EMPTY_DELAY_MS); return; } if (SCENARIO === "SLOW") { 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 index 222f0fdf..a7897b14 100644 --- 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 @@ -62,16 +62,26 @@ internal class EmbeddedBlockWebViewProvider( when (message) { is TempEmbeddedBlockPageMessage.Ready -> applyHeight(message.heightCssPx) - is TempEmbeddedBlockPageMessage.HeightChanged -> 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) { - // Zero height means the page worked and its targeting matched nothing — empty, not broken. if (heightCssPx <= 0) { - mindboxLogI("[EmbeddedBlock] Block page reported zero height — nothing to show") + mindboxLogW("[EmbeddedBlock] Block page reported zero height in ready, treating it as broken") isReady = false - report(EmbeddedBlockState.Empty) + report(EmbeddedBlockState.Failed) page.pause() return } 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 index eecf7eca..59ec8901 100644 --- 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 @@ -1,10 +1,14 @@ package cloud.mindbox.mobile_sdk.embedded.webview -// Shared with deployed pages and the iOS SDK: renaming either value breaks every published page, -// so it has to happen together with the web team. internal object TempEmbeddedBlockPageContract { - const val BRIDGE_NAME: String = "mindboxStoriesFeed" + // 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 index c376b920..7a9ca647 100644 --- 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 @@ -8,6 +8,13 @@ internal sealed class 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 { private const val KEY_TYPE = "type" @@ -24,6 +31,7 @@ internal sealed class TempEmbeddedBlockPageMessage { when (payload.optString(KEY_TYPE)) { "ready" -> height(payload)?.let { Ready(it) } "heightChanged" -> height(payload)?.let { HeightChanged(it) } + "empty" -> Empty else -> null } 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 index 0863beb8..3548a41b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt @@ -415,6 +415,35 @@ class MindboxEmbeddedBlockViewTest { assertEquals(listOf("fail"), listener.events) } + @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() 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 index 63ec502f..33cac670 100644 --- 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 @@ -107,6 +107,19 @@ class EmbeddedBlockWebViewPageTest { ) } + @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() 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 index acc95779..e2e1c29d 100644 --- 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 @@ -75,26 +75,52 @@ class EmbeddedBlockWebViewProviderTest { provider.start() page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) - // The height is only validated (zero → Empty, implausible → Failed), never carried: + // 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 after ready keeps the block Ready`() { + fun `heightChanged is not a second way to say ready`() { provider.start() - page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = 104.0)) 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.HeightChanged(heightCssPx = 0.0)) + page.send(TempEmbeddedBlockPageMessage.Empty) // Content can disappear live (every story watched, targeting re-evaluated). assertEquals(EmbeddedBlockState.Empty, states.last()) @@ -102,23 +128,24 @@ class EmbeddedBlockWebViewProviderTest { } @Test - fun `zero height means nothing to show and empties the block`() { + 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 worked, its targeting just matched nothing — the empty state, not a failure. - assertEquals(EmbeddedBlockState.Empty, states.last()) + // 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) - // The buried page is silenced: invisible content must not keep running JS. assertEquals(1, page.pauseCount) } @Test - fun `a negative height is treated as nothing to show`() { + fun `a negative height is broken the same way`() { provider.start() page.send(TempEmbeddedBlockPageMessage.Ready(heightCssPx = -10.0)) - assertEquals(EmbeddedBlockState.Empty, states.last()) + assertEquals(EmbeddedBlockState.Failed, states.last()) } @Test From 623196c63b5102b26cd693ccebd15fd40c9d31e0 Mon Sep 17 00:00:00 2001 From: sozinov Date: Tue, 11 Aug 2026 17:49:42 +0300 Subject: [PATCH 7/8] MOBILE-324: Drop the jvm-default flag, add a listener adapter instead --- modulesCommon.gradle | 4 -- .../embedded/MindboxEmbeddedBlockListener.kt | 4 +- .../MindboxEmbeddedBlockListenerAdapter.kt | 25 +++++++++++ .../embedded/MindboxEmbeddedBlockViewTest.kt | 44 +++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListenerAdapter.kt diff --git a/modulesCommon.gradle b/modulesCommon.gradle index 9944f1d7..9e621e5e 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -46,10 +46,6 @@ android { kotlinOptions { jvmTarget = '11' - // Interface default bodies must be real JVM default methods: a Java host implementing a - // listener overrides only what it needs. all-compatibility keeps DefaultImpls for - // binary compatibility with already-published code. - freeCompilerArgs += ['-Xjvm-default=all-compatibility'] } kotlin { 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 index f6b35142..84c4288a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockListener.kt @@ -7,9 +7,11 @@ package cloud.mindbox.mobile_sdk.embedded * 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. + * 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 { 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/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt index 3548a41b..df0f5edc 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewTest.kt @@ -415,6 +415,50 @@ class MindboxEmbeddedBlockViewTest { 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() From 64f7cf684392e1006b380be029b31dfa2a3c3801 Mon Sep 17 00:00:00 2001 From: sozinov Date: Tue, 11 Aug 2026 17:50:06 +0300 Subject: [PATCH 8/8] MOBILE-324: Make every temporary piece announce itself --- .../embedded/TempEmbeddedBlocksConfig.kt | 2 ++ .../embedded/mock/TempEmbeddedBlockUsage.kt | 25 +++++++++++++++++++ .../TempEmbeddedBlocksMockConfigSection.kt | 1 + .../mock/TempMindboxStoriesFeedMock.kt | 6 +++++ .../embedded/mock/TempStoriesFeedMockPage.kt | 6 +++-- .../webview/TempEmbeddedBlockPageContract.kt | 6 +++++ .../webview/TempEmbeddedBlockPageMessage.kt | 7 ++++++ .../MobileConfigRepositoryImpl.kt | 6 ++++- 8 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/mock/TempEmbeddedBlockUsage.kt 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 index 124ea1a0..3d2850eb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/TempEmbeddedBlocksConfig.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/TempEmbeddedBlocksConfig.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.embedded +import cloud.mindbox.mobile_sdk.embedded.mock.TempEmbeddedBlockUsage import org.json.JSONArray import org.json.JSONObject @@ -27,6 +28,7 @@ internal data class TempEmbeddedBlocksConfig( 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 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 index 04bf2b9d..5d19625d 100644 --- 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 @@ -21,6 +21,7 @@ internal object TempEmbeddedBlocksMockConfigSection { 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. 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 index d7cd2e47..3927b3bb 100644 --- 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 @@ -29,5 +29,11 @@ public object TempMindboxStoriesFeedMock { 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 index 8126f7d9..a9c49b02 100644 --- 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 @@ -4,8 +4,10 @@ package cloud.mindbox.mobile_sdk.embedded.mock // page contract stays identical across platforms. internal object TempStoriesFeedMockPage { - fun html(scenario: TempMindboxStoriesFeedMock.Scenario): String = - PAGE_TEMPLATE.replace("__SCENARIO__", scenario.name) + 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 = """ 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 index 59ec8901..2235e28a 100644 --- 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 @@ -1,7 +1,13 @@ 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. 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 index 7a9ca647..dce8c7e6 100644 --- 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 @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.embedded.webview +import cloud.mindbox.mobile_sdk.embedded.mock.TempEmbeddedBlockUsage import org.json.JSONObject internal sealed class TempEmbeddedBlockPageMessage { @@ -17,6 +18,12 @@ internal sealed class 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" 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 a03a80f3..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,7 @@ 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 @@ -74,7 +75,10 @@ internal class MobileConfigRepositoryImpl( val configuration = DbManager.listenConfigurations().first() // 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. + // 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