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 3e261f53..0899999d 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 @@ -3,6 +3,7 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -14,9 +15,12 @@ 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.Mindbox import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView +import cloud.mindbox.mobile_sdk.logger.Level /** * An embedded Mindbox block as a composable. @@ -29,8 +33,9 @@ import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView * * 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. + * content — unless it failed and the [error] slot is set: that slot is a request to show a + * failure, so the block stays and shows it. An empty place collapses either way. The callbacks + * only report the outcome. * * ```kotlin * MindboxEmbeddedBlock( @@ -43,20 +48,25 @@ import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView * @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 timeoutMs How long the block waits to learn what it shows before collapsing as empty, in + * milliseconds. `null` means the SDK default of 30 s. Fixed when the block is created, as the + * place is: a new value given to a block already on screen is ignored, and the block says so in + * the log. Wrap the block in a `key()` of your own to build one on a different budget. * @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. + * @param error The view for a block that failed. Setting it keeps the block visible instead of + * the default collapse; an empty place collapses regardless. Fills the whole block frame. */ @OptIn(InternalMindboxApi::class) @Composable public fun MindboxEmbeddedBlock( placeSystemName: String, modifier: Modifier = Modifier, + timeoutMs: Long? = null, onLoad: () -> Unit = {}, onFail: () -> Unit = {}, placeholder: (@Composable () -> Unit)? = null, @@ -70,7 +80,22 @@ public fun MindboxEmbeddedBlock( val context = LocalContext.current key(placeSystemName) { - var isCollapsed by remember { mutableStateOf(false) } + var appearance by remember { + mutableStateOf(MindboxEmbeddedBlockAppearance.PLACEHOLDER) + } + + val creationTimeoutMs = remember { timeoutMs } + if (timeoutMs != creationTimeoutMs) { + LaunchedEffect(timeoutMs) { + Mindbox.writeLog( + "[EmbeddedBlock] Block '$placeSystemName' was given timeoutMs=$timeoutMs after " + + "creation and keeps $creationTimeoutMs: the timeout is fixed when the block " + + "is created. Wrap the block in a key() of your own to build one on a " + + "different budget.", + Level.WARN, + ) + } + } val placeholderHost = remember(context) { lazy(LazyThreadSafetyMode.NONE) { @@ -84,10 +109,16 @@ public fun MindboxEmbeddedBlock( } AndroidView( - modifier = (if (isCollapsed) Modifier.height(0.dp).then(modifier) else modifier).fillMaxWidth(), + modifier = ( + if (appearance == MindboxEmbeddedBlockAppearance.COLLAPSED) { + Modifier.height(0.dp).then(modifier) + } else { + modifier + } + ).fillMaxWidth(), factory = { viewContext -> - MindboxEmbeddedBlockView(viewContext, placeSystemName).apply { - setVisibilityObserver { isVisible -> isCollapsed = !isVisible } + MindboxEmbeddedBlockView(viewContext, placeSystemName, timeoutMs).apply { + setAppearanceObserver { shown -> appearance = shown } setListener( object : MindboxEmbeddedBlockListener { override fun onLoad(view: MindboxEmbeddedBlockView) { 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 1b9ea9e0..1491eb9e 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 @@ -13,12 +13,14 @@ 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.assertEquals 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 +import org.robolectric.shadows.ShadowLog @RunWith(RobolectricTestRunner::class) class MindboxEmbeddedBlockTest { @@ -159,4 +161,51 @@ class MindboxEmbeddedBlockTest { compose.onNodeWithTag("block").assertDoesNotExist() } + + @Test + fun `a budget changed after creation is ignored, and said out loud`() { + val timeout = mutableStateOf(5_000) + + compose.setContent { + MindboxEmbeddedBlock( + placeSystemName = "main-screen-top", + modifier = Modifier.height(120.dp), + timeoutMs = timeout.value, + ) + } + settle() + assertTrue(timeoutWarnings().isEmpty()) + + compose.runOnUiThread { timeout.value = 60_000 } + settle() + + val said = timeoutWarnings() + assertEquals(1, said.size) + assertTrue(said.single().contains("timeoutMs=60000")) + assertTrue(said.single().contains("keeps 5000")) + + compose.runOnUiThread { timeout.value = 60_000 } + settle() + assertEquals(1, timeoutWarnings().size) + } + + @Test + fun `a budget left alone says nothing`() { + compose.setContent { + MindboxEmbeddedBlock( + placeSystemName = "main-screen-top", + modifier = Modifier.height(120.dp), + timeoutMs = 5_000, + ) + } + settle() + compose.runOnUiThread { compose.activity.setTitle("recompose") } + settle() + + assertTrue(timeoutWarnings().isEmpty()) + } + + private fun timeoutWarnings(): List = ShadowLog.getLogs() + .filter { log -> log.msg?.contains("was given timeoutMs=") == true } + .map { log -> log.msg } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt index 3f9060f3..9816af39 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.io.Closeable +import java.lang.ref.WeakReference /** * One registered block: how the registry talks back to a view. @@ -40,7 +41,10 @@ internal class EmbeddedBlocksRegistryImpl( private val scopeProvider: () -> CoroutineScope = { Mindbox.mindboxScope }, ) : EmbeddedBlocksRegistry { - private val handlesByPlace = mutableMapOf>() + // Weak on purpose: a host with no lifecycle owner to say goodbye — a plain Dialog, a + // PopupWindow, an app that swaps views itself — only lets its block go, and a strong entry in + // this process-wide map would keep the block, its view and the Activity behind it alive. + private val handlesByPlace = mutableMapOf>>() private val resolvingPlaces = mutableSetOf() private val reResolveQueuedPlaces = mutableMapOf() @@ -68,7 +72,7 @@ internal class EmbeddedBlocksRegistryImpl( scope.launch { inAppInteractor.listenEmbeddedPlaceEvents().collect { placeEvent -> runOnMain { - onPlaceEvent(placeEvent.placeSystemName.trim(), placeEvent.triggerEvent) + onPlaceEvent(placeEvent.placeSystemName, placeEvent.triggerEvent) } } }, @@ -82,35 +86,48 @@ internal class EmbeddedBlocksRegistryImpl( } override fun register(placeSystemName: String, handle: EmbeddedBlockHandle): Closeable { - val place = placeSystemName.trim() runOnMain { restartChannelsIfDead() - handlesByPlace.getOrPut(place) { mutableListOf() }.add(handle) - mindboxLogI("[EmbeddedBlock] Block registered for place '$place'") + handlesByPlace.getOrPut(placeSystemName) { mutableListOf() }.add(WeakReference(handle)) + mindboxLogI("[EmbeddedBlock] Block registered for place '$placeSystemName'") } return Closeable { runOnMain { - handlesByPlace[place]?.remove(handle) - if (handlesByPlace[place]?.isEmpty() == true) { - handlesByPlace.remove(place) - reResolveQueuedPlaces.remove(place) + handlesByPlace[placeSystemName]?.removeAll { reference -> + reference.get().let { registered -> registered === handle || registered == null } } - mindboxLogI("[EmbeddedBlock] Block unregistered from place '$place'") + forgetPlaceIfEmpty(placeSystemName) + mindboxLogI("[EmbeddedBlock] Block unregistered from place '$placeSystemName'") } } } override fun onBlockAppeared(placeSystemName: String) { - val place = placeSystemName.trim() runOnMain { restartChannelsIfDead() - resolvePlace(place) + resolvePlace(placeSystemName) } } + private fun liveHandles(place: String): List { + val references = handlesByPlace[place] ?: return emptyList() + val handles = references.mapNotNull { reference -> reference.get() } + if (handles.size != references.size) { + references.removeAll { reference -> reference.get() == null } + forgetPlaceIfEmpty(place) + } + return handles + } + + private fun forgetPlaceIfEmpty(place: String) { + if (handlesByPlace[place]?.isEmpty() != true) return + handlesByPlace.remove(place) + reResolveQueuedPlaces.remove(place) + } + private fun onPlaceEvent(place: String, triggerEvent: InAppEventType) { - val handles = handlesByPlace[place] - if (handles.isNullOrEmpty()) { + val handles = liveHandles(place) + if (handles.isEmpty()) { mindboxLogI("[EmbeddedBlock] Operation matched place '$place' but no block is registered, dropping") return } @@ -156,23 +173,27 @@ internal class EmbeddedBlocksRegistryImpl( } private fun invalidateAll(reason: String) { - handlesByPlace.forEach { (place, handles) -> - if (handles.any { handle -> handle.isActive }) { - mindboxLogI("[EmbeddedBlock] Re-resolving place '$place' ($reason)") - resolvePlace(place) - } else { - mindboxLogI("[EmbeddedBlock] Place '$place' is paused, nowhere to display — skipping ($reason)") + handlesByPlace.keys.toList().forEach { place -> + val handles = liveHandles(place) + when { + handles.isEmpty() -> Unit + handles.any { handle -> handle.isActive } -> { + mindboxLogI("[EmbeddedBlock] Re-resolving place '$place' ($reason)") + resolvePlace(place) + } + else -> + mindboxLogI("[EmbeddedBlock] Place '$place' is paused, nowhere to display — skipping ($reason)") } } } private fun deliver(place: String, content: InAppType.Embedded?) { - val handles = handlesByPlace[place] - if (handles.isNullOrEmpty()) { + val handles = liveHandles(place) + if (handles.isEmpty()) { mindboxLogW("[EmbeddedBlock] No block is registered for place '$place', dropping the content") return } - handles.toList().forEach { handle -> + handles.forEach { handle -> loggingRunCatching { handle.onContentResolved(content) } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockAppearance.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockAppearance.kt new file mode 100644 index 00000000..651296e8 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockAppearance.kt @@ -0,0 +1,36 @@ +package cloud.mindbox.mobile_sdk.embedded + +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi + +/** + * How a [MindboxEmbeddedBlockView] occupies its place right now — what is drawn, not what happened. + * + * For wrappers that lay the block out themselves instead of relying on the view's own + * `visibility`. The rules behind the decision stay here: that an empty place shows no error view, + * that a place taken by loading is a place drawn, that a settled block keeps what it shows. A + * wrapper mirrors the answer in its own layout and nothing more, so every wrapper of the SDK shows + * the same thing at the same moment by construction. + * + * Deliberately not part of the public API: available to wrappers through [InternalMindboxApi]. + */ +@InternalMindboxApi +public enum class MindboxEmbeddedBlockAppearance { + + /** The content is loading. The block takes its place and draws a loading screen. */ + PLACEHOLDER, + + /** The block content is shown. */ + CONTENT, + + /** + * The block failed and the host opted into showing it by setting + * [MindboxEmbeddedBlockView.setErrorView]. Never appears for an empty place. + */ + ERROR, + + /** + * The block occupies no space: a failure without a host error view, or an empty place. The + * space goes back to the layout. + */ + COLLAPSED, +} 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 84c4288a..d2f9adc3 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 @@ -27,9 +27,10 @@ public interface MindboxEmbeddedBlockListener { * The place stays without content — the load failed or timed out, or the config had nothing * to put here. An empty place is a normal outcome, not a breakage. * - * The block already hid itself, unless [MindboxEmbeddedBlockView.setErrorView] is set — then - * it keeps its place and shows that view. Nothing is required here: the block retries by - * itself, and how depends on why the place stayed empty. + * The block already hid itself, unless it failed and [MindboxEmbeddedBlockView.setErrorView] + * is set — then it keeps its place and shows that view. An empty place collapses either way. + * 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. 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 97977be3..1f3f0000 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 @@ -34,7 +34,8 @@ import kotlin.math.abs * **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]). + * block hides itself; a failure can be shown instead of hidden if the host gave it a view for one + * ([setErrorView]). * * ```xml * EmbeddedBlockContentFactory.createProvider(context, content, attemptStartedAt) }, @@ -69,20 +72,30 @@ public class MindboxEmbeddedBlockView internal constructor( attrs: AttributeSet? = null, ) : this(context, attrs, readPlaceSystemName(context, attrs)) + /** + * Creates a block for [placeSystemName] in code, where there is no XML to carry the attributes. + * + * @param timeoutMs How long the block waits to learn what it shows before collapsing as empty, + * in milliseconds — the same budget `app:mindboxTimeoutMs` sets from XML. `null` means the SDK + * default of 30 s. An answer that arrives after that no longer expands the block; the next + * attempt starts when the block enters the window again. + */ + @JvmOverloads public constructor( context: Context, placeSystemName: String, - ) : this(context, null, placeSystemName) + timeoutMs: Long? = null, + ) : this(context, null, placeSystemName, timeoutMs?.let(::Milliseconds)) - public val placeSystemName: String? = placeSystemName.orNullIfBlank() + public val placeSystemName: String? = placeSystemName.orNullIfEmpty() private var listener: MindboxEmbeddedBlockListener = DefaultListener - private var visibilityObserver: ((Boolean) -> Unit)? = null + private var appearanceObserver: ((MindboxEmbeddedBlockAppearance) -> 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 enum class BlockEvent { LOADED, FAILED } private var state: EmbeddedBlockState = EmbeddedBlockState.Loading set(value) { @@ -92,7 +105,11 @@ public class MindboxEmbeddedBlockView internal constructor( private var deliveredEvent: BlockEvent? = null private var isDeliveryScheduled = false - private var hasCollapsed = false + private var hasSettled = false + private var shownAppearance = MindboxEmbeddedBlockAppearance.PLACEHOLDER + private var isWindowVisible = false + private var isHostVisible = true + private var isReleased = false private var shownContent: View? = null private var isContentStarted = false private var observedLifecycle: Lifecycle? = null @@ -124,6 +141,8 @@ public class MindboxEmbeddedBlockView internal constructor( } public fun setListener(listener: MindboxEmbeddedBlockListener?) { + if (isReleased) return + 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 @@ -145,23 +164,68 @@ public class MindboxEmbeddedBlockView internal constructor( */ public fun setPlaceholderView(view: View?) { placeholderView = view - if (state is EmbeddedBlockState.Loading) showContent(currentPlaceholder()) + if (shownAppearance == MindboxEmbeddedBlockAppearance.PLACEHOLDER) { + 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. + * The view for a block that failed. Setting it opts into showing the failure instead of the + * default collapse: the block keeps its place and shows this view. Fills the whole block frame. * - * Applies from the next outcome on: a block that already collapsed stays collapsed until - * its content reloads. + * Applies to failures only — an empty place always collapses, so a host cannot fill the space + * of a block that was never meant to be there. + * + * A view set mid-failure swaps the error screen already shown, and `null` given while one is + * shown takes the failure back down to a collapse — the space returns to the layout. What + * neither does is expand a block that has already collapsed: reopening space the layout has + * reclaimed would make it jump, so such a view takes effect on a load that starts the cycle + * anew, never on the silent retry a return to the screen brings. */ public fun setErrorView(view: View?) { errorView = view + if (shownAppearance == MindboxEmbeddedBlockAppearance.ERROR) { + applyState(state) + } + } + + /** + * Reports how the block occupies its place, for wrappers that lay it out themselves instead of + * relying on this view's own `visibility` — see [MindboxEmbeddedBlockAppearance]. + * + * The current value arrives right away on subscribing: a wrapper that comes after the outcome + * cannot miss what the block already decided. + */ + @InternalMindboxApi + public fun setAppearanceObserver(observer: ((MindboxEmbeddedBlockAppearance) -> Unit)?) { + if (isReleased) return + + appearanceObserver = observer + loggingRunCatching { observer?.invoke(shownAppearance) } } + /** + * Tells the block whether the host still shows it — a second source for the same input as + * window visibility: the content runs while the window shows it and the host says so. + * + * For wrappers whose whole app lives in one window. In Flutter every screen shares it, so + * leaving a screen never takes the block out of a window: the block would keep waiting — and + * spending its budget — on a screen nobody is looking at, and could collapse before the user + * ever got there. `true` by default, so a wrapper that says nothing behaves as before. + * + * A pause, not a reset: a block hidden mid-load keeps the page it has and the remainder of its + * budget; shown again, it counts that remainder down instead of starting the budget anew. + */ @InternalMindboxApi - public fun setVisibilityObserver(observer: ((isVisible: Boolean) -> Unit)?) { - visibilityObserver = observer + public fun setHostVisible(visible: Boolean) { + if (isHostVisible == visible) return + + isHostVisible = visible + mindboxLogI( + "[EmbeddedBlock] Block '$placeSystemName' was ${if (visible) "shown" else "hidden"} " + + "by the host wrapper", + ) + updateContentActivity() } private val hasCustomErrorView: Boolean @@ -170,17 +234,27 @@ public class MindboxEmbeddedBlockView internal constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() observeHostDestruction() - if (windowVisibility == VISIBLE) startContent() + isWindowVisible = windowVisibility == VISIBLE + updateContentActivity() } override fun onDetachedFromWindow() { - pauseContent() + isWindowVisible = false + updateContentActivity() super.onDetachedFromWindow() } override fun onWindowVisibilityChanged(visibility: Int) { super.onWindowVisibilityChanged(visibility) - if (visibility == VISIBLE) startContent() else pauseContent() + isWindowVisible = visibility == VISIBLE + updateContentActivity() + } + + private val isEffectivelyVisible: Boolean + get() = isWindowVisible && isHostVisible && !isReleased + + private fun updateContentActivity() { + if (isEffectivelyVisible) startContent() else pauseContent() } private fun startContent() { @@ -198,6 +272,7 @@ public class MindboxEmbeddedBlockView internal constructor( } private fun observeHostDestruction(): Unit = loggingRunCatching { + if (isReleased) return@loggingRunCatching val lifecycle = findViewTreeLifecycleOwner()?.lifecycle ?: return@loggingRunCatching if (lifecycle === observedLifecycle) return@loggingRunCatching observedLifecycle?.removeObserver(hostDestroyObserver) @@ -205,9 +280,26 @@ public class MindboxEmbeddedBlockView internal constructor( lifecycle.addObserver(hostDestroyObserver) } + /** + * Stops the block for good: the content stops, the host screen is no longer observed, the + * listener is dropped, and the block does not start again even while it stays in a window. + * + * For wrappers whose own object graph dies before the view leaves the window — a platform-view + * factory keeps the view for as long as the platform sees fit, and the block should stop when + * the screen is gone rather than when the last reference is. A host application needs nothing: + * a block that leaves the window pauses itself, and the destruction of the host screen frees it. + * + * One way only: a released block stays released, and [setListener] and [setAppearanceObserver] + * on it do nothing. + */ @InternalMindboxApi public fun release() { + if (isReleased) return + mindboxLogI("[EmbeddedBlock] Released by the host wrapper, freeing content") + isReleased = true + appearanceObserver = null + updateContentActivity() detachFromHost() loggingRunCatching { contentController.release() } } @@ -248,58 +340,71 @@ public class MindboxEmbeddedBlockView internal constructor( } 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 -> { + if (state is EmbeddedBlockState.Ready && contentController.contentView == null) { + mindboxLogW("[EmbeddedBlock] Ready content has no view, treating it as a failure") + this.state = EmbeddedBlockState.Failed + return + } + + val appearance = appearanceFor(state) + shownAppearance = appearance + hasSettled = when (appearance) { + MindboxEmbeddedBlockAppearance.COLLAPSED, + MindboxEmbeddedBlockAppearance.ERROR, + -> true + MindboxEmbeddedBlockAppearance.CONTENT -> false + MindboxEmbeddedBlockAppearance.PLACEHOLDER -> hasSettled + } + + when (appearance) { + MindboxEmbeddedBlockAppearance.PLACEHOLDER -> { mindboxLogI("[EmbeddedBlock] Content loading, showing the placeholder") showContent(currentPlaceholder()) } - - is EmbeddedBlockState.Empty -> { - mindboxLogI("[EmbeddedBlock] Nothing to show for this place") - showErrorView() + MindboxEmbeddedBlockAppearance.CONTENT -> { + mindboxLogI("[EmbeddedBlock] Content ready") + contentController.contentView?.let { showContent(it) } } - is EmbeddedBlockState.Failed -> { - mindboxLogI("[EmbeddedBlock] Content failed, showing the error state") - showErrorView() + MindboxEmbeddedBlockAppearance.ERROR -> { + mindboxLogI("[EmbeddedBlock] Content failed, showing the host's error view") + errorView?.let { showContent(it) } + } + MindboxEmbeddedBlockAppearance.COLLAPSED -> { + mindboxLogI("[EmbeddedBlock] Nothing to show for this place, collapsing") + clearContent() } } - applyDefaultVisibility(state) + visibility = if (appearance == MindboxEmbeddedBlockAppearance.COLLAPSED) GONE else VISIBLE + loggingRunCatching { appearanceObserver?.invoke(appearance) } scheduleDelivery() } - private fun applyDefaultVisibility(state: EmbeddedBlockState) { - val isVisible = when { - state.nothingToShow -> hasCustomErrorView - state is EmbeddedBlockState.Loading -> !hasCollapsed - else -> true - } - hasCollapsed = when { - state is EmbeddedBlockState.Ready -> false - state.nothingToShow -> !isVisible - else -> hasCollapsed + private fun appearanceFor(state: EmbeddedBlockState): MindboxEmbeddedBlockAppearance = + when (state) { + is EmbeddedBlockState.Loading -> when { + !hasSettled -> MindboxEmbeddedBlockAppearance.PLACEHOLDER + shownAppearance == MindboxEmbeddedBlockAppearance.ERROR && !hasCustomErrorView -> + MindboxEmbeddedBlockAppearance.COLLAPSED + else -> shownAppearance + } + is EmbeddedBlockState.Ready -> MindboxEmbeddedBlockAppearance.CONTENT + is EmbeddedBlockState.Failed -> when { + !hasSettled -> + if (hasCustomErrorView) { + MindboxEmbeddedBlockAppearance.ERROR + } else { + MindboxEmbeddedBlockAppearance.COLLAPSED + } + shownAppearance == MindboxEmbeddedBlockAppearance.ERROR && !hasCustomErrorView -> + MindboxEmbeddedBlockAppearance.COLLAPSED + else -> shownAppearance + } + is EmbeddedBlockState.Empty -> MindboxEmbeddedBlockAppearance.COLLAPSED } - 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) } @@ -321,10 +426,12 @@ public class MindboxEmbeddedBlockView internal constructor( private fun deliverPendingEvent(): Unit = loggingRunCatching { isDeliveryScheduled = false + // Loading is not an outcome, so the record of what was delivered is left alone: writing it + // down would make the outcome that follows look new even when it is the one already heard. val event = when { state is EmbeddedBlockState.Ready -> BlockEvent.LOADED state.nothingToShow -> BlockEvent.FAILED - else -> BlockEvent.LOADING + else -> return@loggingRunCatching } if (event == deliveredEvent) return@loggingRunCatching @@ -332,7 +439,6 @@ public class MindboxEmbeddedBlockView internal constructor( when (event) { BlockEvent.LOADED -> listener.onLoad(this) BlockEvent.FAILED -> listener.onFail(this) - BlockEvent.LOADING -> Unit } } @@ -341,7 +447,7 @@ public class MindboxEmbeddedBlockView internal constructor( } } -private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() } +private fun String?.orNullIfEmpty(): String? = this?.takeIf { it.isNotEmpty() } private fun readPlaceSystemName(context: Context, attrs: AttributeSet?): String? { if (attrs == null) return null diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt index 43d32d42..c24dabfc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt @@ -44,6 +44,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.Timestamp import cloud.mindbox.mobile_sdk.models.getShortUserAgent import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason @@ -63,6 +64,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference @OptIn(InternalMindboxApi::class) internal class EmbeddedBlockWebViewHolder( @@ -139,15 +141,27 @@ internal class EmbeddedBlockWebViewHolder( private val isUserPresent: Boolean get() = isActive && !isReleased + private val heldFailure = AtomicReference(null) + + private data class HeldFailure( + val failureReason: FailureReason, + val errorDescription: String, + val throwable: Throwable?, + ) + + @Volatile private var renderedTimeToDisplay: Milliseconds? = null + override fun start() { if (isReleased) return isActive = true + flushHeldFailure() if (!isLoadRequested) { isLoadRequested = true load() return } report(lastState) + if (lastState == EmbeddedBlockState.Ready) accountForShow() } override fun pause() { @@ -161,6 +175,7 @@ internal class EmbeddedBlockWebViewHolder( if (commonBridgeActionsLazy.isInitialized()) { commonBridgeActions.tearDown() } + heldFailure.set(null) cancelPendingResponses("Embedded block content is released") webViewController?.let { controller -> controller.setEventListener(null) @@ -265,12 +280,10 @@ internal class EmbeddedBlockWebViewHolder( "description=${error.description}, url=${error.url}" ) if (error.isForMainFrame == true) { - inAppFailureTracker.sendFailureWithContext( - inAppId = inAppId, + sendFailure( failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, errorDescription = "Embedded block WebView error: code=${error.code}, " + "description=${error.description}, url=${error.url}", - tags = null ) report(EmbeddedBlockState.Failed) } @@ -388,8 +401,9 @@ internal class EmbeddedBlockWebViewHolder( report(EmbeddedBlockState.Empty) return BridgeMessage.SUCCESS_PAYLOAD } + renderedTimeToDisplay = timeProvider.elapsedSince(attemptStartedAt) report(EmbeddedBlockState.Ready) - accountForShow() + if (isActive) accountForShow() return BridgeMessage.SUCCESS_PAYLOAD } @@ -407,11 +421,9 @@ internal class EmbeddedBlockWebViewHolder( */ private fun refusedContentReport(reason: String): Throwable { mindboxLogE("[EmbeddedBlock] contentRendered refused: $reason") - inAppFailureTracker.sendFailureWithContext( - inAppId = inAppId, + sendFailure( failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "The embedded block page reported contentRendered with an unusable payload: $reason", - tags = null ) report(EmbeddedBlockState.Failed) return IllegalArgumentException(reason) @@ -424,7 +436,7 @@ internal class EmbeddedBlockWebViewHolder( private fun accountForShow() { if (didAccountForShow) return didAccountForShow = true - val timeToDisplay = timeProvider.elapsedSince(attemptStartedAt) + val timeToDisplay = renderedTimeToDisplay ?: timeProvider.elapsedSince(attemptStartedAt) Mindbox.mindboxScope.launch { loggingRunCatchingSuspending { inAppInteractor.recordBlockShow(inAppId, timeToDisplay, gatedTags()) @@ -478,13 +490,45 @@ internal class EmbeddedBlockWebViewHolder( onContentPageLoaded(content) } - private fun reportLoadFailure(description: String, throwable: Throwable?) { + private fun sendFailure( + failureReason: FailureReason, + errorDescription: String, + throwable: Throwable? = null, + ) { + if (!isActive) { + mindboxLogI( + "[EmbeddedBlock] $failureReason for $inAppId happened off screen, holding the " + + "report until the block is looked at" + ) + heldFailure.compareAndSet(null, HeldFailure(failureReason, errorDescription, throwable)) + if (isActive) flushHeldFailure() + return + } inAppFailureTracker.sendFailureWithContext( inAppId = inAppId, + failureReason = failureReason, + errorDescription = errorDescription, + throwable = throwable, + tags = null + ) + } + + private fun flushHeldFailure() { + val held = heldFailure.getAndSet(null) ?: return + inAppFailureTracker.sendFailureWithContext( + inAppId = inAppId, + failureReason = held.failureReason, + errorDescription = held.errorDescription, + throwable = held.throwable, + tags = null + ) + } + + private fun reportLoadFailure(description: String, throwable: Throwable?) { + sendFailure( failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = description, throwable = throwable, - tags = null ) webViewController?.executeOnViewThread { report(EmbeddedBlockState.Failed) } ?: mainHandler.post { if (!isReleased) report(EmbeddedBlockState.Failed) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt index fcd40d4e..c3377977 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/InAppMapper.kt @@ -275,7 +275,7 @@ internal class InAppMapper { is PayloadDto.EmbeddedDto -> { InAppType.Embedded( inAppId = inAppDto.id, - placeSystemName = payloadDto.placeSystemName!!.trim(), + placeSystemName = payloadDto.placeSystemName!!, layers = mapBackgroundLayers( payloadDto.content?.background?.layers ?.filterIsInstance() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt index 77e1a35d..ebc9764d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidator.kt @@ -20,7 +20,7 @@ internal class EmbeddedVariantValidator( ) return false } - if (item.placeSystemName.isNullOrBlank()) { + if (item.placeSystemName.isNullOrEmpty()) { mindboxLogW("InApp is invalid. Embedded variant has no placeSystemName") return false } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt index 0cdb7b79..ca03f82a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppFilteringManagerImpl.kt @@ -53,14 +53,13 @@ internal class InAppFilteringManagerImpl( inApps: List, placeSystemName: String ): List { - val requestedPlace = placeSystemName.trim() return inApps.filter { inApp -> inApp.embeddedVariants().any { variant -> - val matches = variant.placeSystemName == requestedPlace - if (!matches && variant.placeSystemName.equals(requestedPlace, ignoreCase = true)) { + val matches = variant.placeSystemName == placeSystemName + if (!matches && variant.placeSystemName.equals(placeSystemName, ignoreCase = true)) { mindboxLogW( "Place names differ only in letter case: config has " + - "'${variant.placeSystemName}', the block asked for '$requestedPlace'. " + + "'${variant.placeSystemName}', the block asked for '$placeSystemName'. " + "The comparison is case-sensitive, the candidate is skipped" ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index da75b928..2766e964 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -116,29 +116,28 @@ internal class InAppInteractorImpl( placeSystemName: String, triggerEvent: InAppEventType, ): InAppType.Embedded? { - val requestedPlace = placeSystemName.trim() val inApps = mobileConfigRepository.getInAppsSection() inAppRepository.saveCurrentSessionInApps(inApps) // The same chain as the event path: the display style does not change "when to show". val candidates = abTestFilteredInApps(inApps) - .let { inAppFilteringManager.filterEmbeddedInAppsByPlace(it, requestedPlace) } + .let { inAppFilteringManager.filterEmbeddedInAppsByPlace(it, placeSystemName) } val winner = chooseAmongCandidates( - logLabel = "Place '$requestedPlace'", + logLabel = "Place '$placeSystemName'", candidates = candidates, triggerEvent = triggerEvent, selectVariant = { candidate -> candidate.form.variants .filterIsInstance() - .firstOrNull { variant -> variant.placeSystemName == requestedPlace } + .firstOrNull { variant -> variant.placeSystemName == placeSystemName } } ) ?: run { - logI("Place '$requestedPlace': nothing to show") + logI("Place '$placeSystemName': nothing to show") return null } if (!areShowLimitsAllowed(winner)) { - logI("Place '$requestedPlace': in-app ${winner.id} is blocked by the show limits") + logI("Place '$placeSystemName': in-app ${winner.id} is blocked by the show limits") return null } // Place resolves repeat for reasons that offer nothing new — the block reappears, a config @@ -147,11 +146,11 @@ internal class InAppInteractorImpl( if (sessionStorageManager.placeTargetingReportedInSession.add(winner.id)) { inAppProcessingManager.sendTargetedInApp(winner) } else { - logI("Place '$requestedPlace': in-app ${winner.id} already sent its targeting this session") + logI("Place '$placeSystemName': in-app ${winner.id} already sent its targeting this session") } return winner.form.variants .filterIsInstance() - .firstOrNull { variant -> variant.placeSystemName == requestedPlace } + .firstOrNull { variant -> variant.placeSystemName == placeSystemName } } private suspend fun abTestFilteredInApps(inApps: List): List = diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt index 35f6ba70..a25870f3 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt @@ -18,11 +18,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher 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.RobolectricTestRunner import org.robolectric.Shadows.shadowOf +import java.lang.ref.WeakReference /** * The central controller is deliberately dumb: a registry and a router. Both directions call @@ -129,6 +131,36 @@ class EmbeddedBlocksRegistryTest { coVerify(exactly = 0) { interactor.selectInAppForPlace(any(), any()) } } + @Test + fun `a block whose host is gone stops holding its place`() { + val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) + val controller = controller() + var handle: RecordingHandle? = RecordingHandle() + val handleReference = WeakReference(handle) + controller.register(place, handle!!) + idleMain() + + // A host without a lifecycle owner never says goodbye — it only lets the block go. The + // registry must not be what keeps it, and the Activity behind it, in this process. + handle = null + awaitCollection(handleReference) + + scope.launch { placeEvents.emit(EmbeddedPlaceEvent(place, operation)) } + idleMain() + + coVerify(exactly = 0) { interactor.selectInAppForPlace(any(), any()) } + } + + private fun awaitCollection(reference: WeakReference<*>) { + repeat(50) { + if (reference.get() == null) return + System.gc() + System.runFinalization() + Thread.sleep(10L) + } + assertNull("the registry is still holding the handle of a host that is gone", reference.get()) + } + @Test fun `operation for a paused place is skipped and the next appearance resolves fresh`() { val operation = InAppEventType.OrdinalEvent(EventType.AsyncOperation("story-operation")) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewCollapseTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewCollapseTest.kt index de58a803..f8a4367f 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewCollapseTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewCollapseTest.kt @@ -47,17 +47,33 @@ class MindboxEmbeddedBlockViewCollapseTest { override fun release() = Unit } + private class FailingProvider(context: Activity) : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View = View(context) + + override fun start() { + onStateChange?.invoke(EmbeddedBlockState.Failed) + } + + override fun pause() = Unit + + override fun release() = Unit + } + private val activity: Activity = Robolectric.buildActivity(Activity::class.java).setup().get() private val blocksRegistry = FakeBlocksRegistry() + private var lastProvider: EmbeddedContentProvider? = null - private fun buildView(): MindboxEmbeddedBlockView = + private fun buildView( + provider: () -> EmbeddedContentProvider = { ReadyProvider(activity) }, + ): MindboxEmbeddedBlockView = MindboxEmbeddedBlockView( activity, null, "main-screen-top", - EmbeddedBlockContentController( + contentController = EmbeddedBlockContentController( placeSystemName = "main-screen-top", - providerFactory = { _, _ -> ReadyProvider(activity) }, + providerFactory = { _, _ -> provider().also { lastProvider = it } }, blocksRegistry = { blocksRegistry }, ), ) @@ -137,5 +153,41 @@ class MindboxEmbeddedBlockViewCollapseTest { assertEquals(View.VISIBLE, view.visibility) } + @Test + fun `an error view given after the collapse does not reopen the space when the retry fails too`() { + val view = buildView(provider = { FailingProvider(activity) }) + attach(view) + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + assertEquals(View.GONE, view.visibility) + + view.setErrorView(View(activity)) + leaveAndReturn(view) + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `an error view given after the collapse shows once content has started the cycle anew`() { + val providers = ArrayDeque(listOf(FailingProvider(activity), ReadyProvider(activity))) + val view = buildView(provider = { providers.removeFirst() }) + attach(view) + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + assertEquals(View.GONE, view.visibility) + view.setErrorView(View(activity)) + + leaveAndReturn(view) + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + assertEquals(View.VISIBLE, view.visibility) + + lastProvider?.onStateChange?.invoke(EmbeddedBlockState.Failed) + idle() + assertEquals(View.VISIBLE, view.visibility) + } + private fun embeddedContent(): InAppType.Embedded = InAppStub.getEmbedded() } 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 index 240ccfaf..053b779b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewLookupTest.kt @@ -78,6 +78,60 @@ class MindboxEmbeddedBlockViewLookupTest { assertEquals(listOf("fail"), listener.events) } + @Test + fun `a timeout given in code is the one the block waits out`() { + val view = MindboxEmbeddedBlockView(activity, "main-screen-top", timeoutMs = 5_000L) + val listener = RecordingListener() + view.setListener(listener) + + attach(view) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(4L)) + + assertEquals(View.VISIBLE, view.visibility) + assertTrue(listener.events.isEmpty()) + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2L)) + + assertEquals(View.GONE, view.visibility) + assertEquals(listOf("fail"), listener.events) + } + + @Test + fun `a block given no timeout in code still reads the one from xml`() { + val attrs = Robolectric.buildAttributeSet() + .addAttribute(R.attr.mindboxPlaceSystemName, "main-screen-top") + .addAttribute(R.attr.mindboxTimeoutMs, "5000") + .build() + + val view = MindboxEmbeddedBlockView(activity, attrs) + val listener = RecordingListener() + view.setListener(listener) + + attach(view) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6L)) + + assertEquals(View.GONE, view.visibility) + assertEquals(listOf("fail"), listener.events) + } + + @Test + fun `a non-positive timeout is not a budget, and the default stands`() { + val view = MindboxEmbeddedBlockView(activity, "main-screen-top", timeoutMs = 0L) + val listener = RecordingListener() + view.setListener(listener) + + attach(view) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(29L)) + + assertEquals(View.VISIBLE, view.visibility) + assertTrue(listener.events.isEmpty()) + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2L)) + + assertEquals(View.GONE, view.visibility) + assertEquals(listOf("fail"), listener.events) + } + @Test fun `a block without a place name hides itself`() { // XML without the attribute (or a programmatic view with no name): nothing to match @@ -113,8 +167,8 @@ class MindboxEmbeddedBlockViewLookupTest { } @Test - fun `a blank place name is the same as none`() { - val view = MindboxEmbeddedBlockView(activity, " ") + fun `an empty place name is the same as none`() { + val view = MindboxEmbeddedBlockView(activity, "") attach(view) @@ -122,6 +176,38 @@ class MindboxEmbeddedBlockViewLookupTest { assertNull(view.placeSystemName) } + @Test + fun `a place name of spaces is a name like any other`() { + // Only emptiness is checked: the name goes to the config as it was given, padding and all, + // and a place nobody named that way simply never resolves. + val view = MindboxEmbeddedBlockView(activity, " ") + + attach(view) + + assertEquals(" ", view.placeSystemName) + assertEquals(View.VISIBLE, view.visibility) + } + + @Test + fun `a place that stays empty reports its outcome once across passes`() { + val view = MindboxEmbeddedBlockView(activity, "main-screen-top") + val listener = RecordingListener() + view.setListener(listener) + + attach(view) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(31L)) + + assertEquals(listOf("fail"), listener.events) + + // A second pass across the screen: the same empty place is the same outcome, not news. + (view.parent as LinearLayout).removeView(view) + shadowOf(Looper.getMainLooper()).idle() + attach(view) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(31L)) + + assertEquals(listOf("fail"), listener.events) + } + @Test fun `the block behaves with no listener at all`() { // The show/hide behavior belongs to the block, not to the host's callbacks. diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewWrapperHooksTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewWrapperHooksTest.kt new file mode 100644 index 00000000..ed643273 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewWrapperHooksTest.kt @@ -0,0 +1,336 @@ +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.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.models.InAppStub +import org.junit.Assert.assertEquals +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.io.Closeable + +@RunWith(RobolectricTestRunner::class) +class MindboxEmbeddedBlockViewWrapperHooksTest { + + private class FakeBlocksRegistry : EmbeddedBlocksRegistry { + var lastHandle: EmbeddedBlockHandle? = null + + override fun register(placeSystemName: String, handle: EmbeddedBlockHandle): Closeable { + lastHandle = handle + return Closeable { lastHandle = null } + } + + override fun onBlockAppeared(placeSystemName: String) = Unit + + override fun startListening() = Unit + } + + private class ReadyProvider(context: Activity) : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View = View(context) + + var startCount = 0 + var pauseCount = 0 + + override fun start() { + startCount++ + onStateChange?.invoke(EmbeddedBlockState.Ready) + } + + override fun pause() { + pauseCount++ + } + + override fun release() = Unit + } + + private val activity: Activity = Robolectric.buildActivity(Activity::class.java).setup().get() + private val blocksRegistry = FakeBlocksRegistry() + private lateinit var provider: ReadyProvider + + private fun buildView(): MindboxEmbeddedBlockView = + MindboxEmbeddedBlockView( + activity, + null, + "main-screen-top", + contentController = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + providerFactory = { _, _ -> ReadyProvider(activity).also { provider = it } }, + blocksRegistry = { blocksRegistry }, + ), + ) + + private fun attach(view: MindboxEmbeddedBlockView) { + activity.setContentView(LinearLayout(activity).apply { addView(view, 500, 300) }) + idle() + dispatchWindowVisibility(view, View.VISIBLE) + idle() + } + + private fun idle() { + shadowOf(Looper.getMainLooper()).idle() + } + + private fun showContent() { + blocksRegistry.lastHandle?.onContentResolved(InAppStub.getEmbedded() as InAppType.Embedded) + idle() + } + + private fun failContent() { + provider.onStateChange?.invoke(EmbeddedBlockState.Failed) + idle() + } + + @Test + fun `subscribing hands out the appearance the block already has`() { + val view = buildView() + attach(view) + blocksRegistry.lastHandle?.onContentResolved(null) + idle() + + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + + assertEquals(listOf(MindboxEmbeddedBlockAppearance.COLLAPSED), seen) + } + + @Test + fun `the appearance follows the block from loading to content`() { + val view = buildView() + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + + attach(view) + assertEquals(MindboxEmbeddedBlockAppearance.PLACEHOLDER, seen.last()) + + showContent() + + assertEquals(MindboxEmbeddedBlockAppearance.CONTENT, seen.last()) + } + + @Test + fun `an empty place collapses even when the host set an error view`() { + val view = buildView() + view.setErrorView(View(activity)) + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + attach(view) + + blocksRegistry.lastHandle?.onContentResolved(null) + idle() + + assertEquals(MindboxEmbeddedBlockAppearance.COLLAPSED, seen.last()) + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `a host that hides the block pauses its content and shows it again on return`() { + val view = buildView() + attach(view) + showContent() + val startsWhenShown = provider.startCount + + view.setHostVisible(false) + idle() + assertEquals(1, provider.pauseCount) + + view.setHostVisible(true) + idle() + + assertTrue(provider.startCount > startsWhenShown) + } + + @Test + fun `the same host visibility twice changes nothing`() { + val view = buildView() + attach(view) + showContent() + + view.setHostVisible(false) + view.setHostVisible(false) + idle() + + assertEquals(1, provider.pauseCount) + } + + @Test + fun `taking the error view away collapses the failure it was showing`() { + val view = buildView() + view.setErrorView(View(activity)) + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + attach(view) + showContent() + failContent() + assertEquals(MindboxEmbeddedBlockAppearance.ERROR, seen.last()) + + view.setErrorView(null) + + assertEquals(MindboxEmbeddedBlockAppearance.COLLAPSED, seen.last()) + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `one error view swapped for another keeps the failure shown`() { + val view = buildView() + view.setErrorView(View(activity)) + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + attach(view) + showContent() + failContent() + + val next = View(activity) + view.setErrorView(next) + + assertEquals(MindboxEmbeddedBlockAppearance.ERROR, seen.last()) + assertEquals(View.VISIBLE, view.visibility) + } + + @Test + fun `an error view given to an already collapsed block does not expand it`() { + val view = buildView() + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + attach(view) + showContent() + failContent() + assertEquals(MindboxEmbeddedBlockAppearance.COLLAPSED, seen.last()) + + view.setErrorView(View(activity)) + + assertEquals(MindboxEmbeddedBlockAppearance.COLLAPSED, seen.last()) + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `taking the error view away collapses a failure still shown after a return`() { + val view = buildView() + view.setErrorView(View(activity)) + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + attach(view) + showContent() + failContent() + assertEquals(MindboxEmbeddedBlockAppearance.ERROR, seen.last()) + + dispatchWindowVisibility(view, View.GONE) + idle() + dispatchWindowVisibility(view, View.VISIBLE) + idle() + assertEquals(MindboxEmbeddedBlockAppearance.ERROR, seen.last()) + + view.setErrorView(null) + + assertEquals(MindboxEmbeddedBlockAppearance.COLLAPSED, seen.last()) + assertEquals(View.GONE, view.visibility) + } + + @Test + fun `an error view swapped after a return replaces the one still on screen`() { + val view = buildView() + view.setErrorView(View(activity)) + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + attach(view) + showContent() + failContent() + + dispatchWindowVisibility(view, View.GONE) + idle() + dispatchWindowVisibility(view, View.VISIBLE) + idle() + + val next = View(activity) + view.setErrorView(next) + + assertEquals(MindboxEmbeddedBlockAppearance.ERROR, seen.last()) + assertEquals(View.VISIBLE, view.visibility) + assertEquals(view, next.parent) + } + + @Test + fun `a released block stops its content before letting it go`() { + val view = buildView() + attach(view) + showContent() + + view.release() + idle() + + assertEquals(1, provider.pauseCount) + } + + @Test + fun `a wrapper callback that throws on subscribing does not reach the host`() { + val view = buildView() + attach(view) + + view.setAppearanceObserver { throw IllegalStateException("the channel is not ready yet") } + + val seen = mutableListOf() + view.setAppearanceObserver { seen.add(it) } + assertEquals(1, seen.size) + } + + @Test + fun `a released block takes no new listener`() { + val view = buildView() + attach(view) + showContent() + view.release() + idle() + + val heard = mutableListOf() + view.setListener( + object : MindboxEmbeddedBlockListener { + override fun onLoad(view: MindboxEmbeddedBlockView) { + heard.add("load") + } + + override fun onFail(view: MindboxEmbeddedBlockView) { + heard.add("fail") + } + }, + ) + idle() + + assertTrue(heard.isEmpty()) + } + + @Test + fun `a released block takes no new appearance observer`() { + val view = buildView() + attach(view) + showContent() + view.release() + idle() + + val seen = mutableListOf() + view.setAppearanceObserver { appearance -> seen.add(appearance) } + idle() + + assertTrue(seen.isEmpty()) + } + + @Test + fun `a released block stays stopped even when the window says it is shown`() { + val view = buildView() + attach(view) + showContent() + view.release() + idle() + val startsBefore = provider.startCount + + dispatchWindowVisibility(view, View.VISIBLE) + idle() + + assertEquals(startsBefore, provider.startCount) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt index f1a75bb3..e90344f1 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt @@ -12,6 +12,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionStatus import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewCachePolicy import cloud.mindbox.mobile_sdk.inapp.presentation.view.MindboxWebPageRegistry import cloud.mindbox.mobile_sdk.inapp.presentation.view.WebViewAction @@ -20,7 +21,10 @@ import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.InAppStub +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.Timestamp +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.gson.JsonPrimitive @@ -60,6 +64,12 @@ class EmbeddedBlockWebViewHolderTest { private val inAppInteractor: InAppInteractor = mockk() private val inAppMessageManager: InAppMessageManager = mockk(relaxUnitFun = true) private val webPageRegistry: MindboxWebPageRegistry = mockk(relaxUnitFun = true) + private val inAppFailureTracker: InAppFailureTracker = mockk(relaxed = true) + + private var elapsed = 0L + private val timeProvider: SystemTimeProvider = mockk { + every { elapsedSince(any()) } answers { Milliseconds(elapsed) } + } private val permissionManager: PermissionManager = mockk { every { getCameraPermissionStatus() } returns PermissionStatus.DENIED every { getLocationPermissionStatus() } returns PermissionStatus.DENIED @@ -80,6 +90,8 @@ class EmbeddedBlockWebViewHolderTest { every { inAppInteractor } returns this@EmbeddedBlockWebViewHolderTest.inAppInteractor every { permissionManager } returns this@EmbeddedBlockWebViewHolderTest.permissionManager every { appContext } returns application + every { inAppFailureTracker } returns this@EmbeddedBlockWebViewHolderTest.inAppFailureTracker + every { timeProvider } returns this@EmbeddedBlockWebViewHolderTest.timeProvider every { webViewCachePolicy } returns mockk { every { isCacheEnabled } returns false } @@ -406,7 +418,6 @@ class EmbeddedBlockWebViewHolderTest { @Test fun `the old checkInappsTargeting name is not spoken anymore`() { - // Cut hard, in sync with iOS: the action never shipped, so no installed SDK speaks it. startAndAwaitPageLoad() postFromPage(request(action = "checkInappsTargeting", payload = """{"inappIds":["story-1"]}""")) @@ -731,6 +742,57 @@ class EmbeddedBlockWebViewHolderTest { assertEquals("response", lastOutgoingMessage()?.get("type")?.asString) } + @Test + fun `a refusal off screen is held until the block comes back`() { + coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + startAndAwaitPageLoad() + + holder.pause() + postFromPage(request(action = "contentRendered", payload = """{"count":"many"}""")) + + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + + holder.start() + + verify(exactly = 1) { + inAppFailureTracker.sendFailure("embedded-id", FailureReason.PRESENTATION_FAILED, any(), any()) + } + } + + @Test + fun `a page that rendered off screen counts its show when the block comes back`() { + coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + startAndAwaitPageLoad() + + holder.pause() + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + + coVerify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any()) } + + holder.start() + + coVerify(exactly = 1, timeout = 5_000L) { + inAppInteractor.recordBlockShow("embedded-id", any(), any()) + } + } + + @Test + fun `the counted show carries the time the render took, not the time off screen`() { + coEvery { inAppInteractor.recordBlockShow(any(), any(), any()) } just runs + startAndAwaitPageLoad() + + holder.pause() + elapsed = 1_000L + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + elapsed = 121_000L + + holder.start() + + coVerify(exactly = 1, timeout = 5_000L) { + inAppInteractor.recordBlockShow("embedded-id", Milliseconds(1_000L), any()) + } + } + @Test fun `release destroys the webview and start after release does nothing`() { startAndAwaitPageLoad() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt index cfcc08e7..42bebb0c 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/mapper/EmbeddedMapperTest.kt @@ -78,7 +78,7 @@ class EmbeddedMapperTest { } @Test - fun `place system name is trimmed on mapping`() { + fun `place system name is mapped as it is`() { val dto = baseDto.copy( form = FormDto(variants = listOf(InAppStub.getEmbeddedDto().copy(placeSystemName = " main-screen-top "))) ) @@ -86,7 +86,7 @@ class EmbeddedMapperTest { val variant = mapper.mapToInAppConfig(config(dto)).inApps.single() .form.variants.single() as InAppType.Embedded - assertEquals("main-screen-top", variant.placeSystemName) + assertEquals(" main-screen-top ", variant.placeSystemName) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt index 0c1db908..ca9b1498 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/validators/EmbeddedVariantValidatorTest.kt @@ -41,7 +41,12 @@ class EmbeddedVariantValidatorTest { @Test fun `missing place system name is invalid`() { assertFalse(validator.isValid(valid.copy(placeSystemName = null))) - assertFalse(validator.isValid(valid.copy(placeSystemName = " "))) + assertFalse(validator.isValid(valid.copy(placeSystemName = ""))) + } + + @Test + fun `a place system name of spaces is a name`() { + assertTrue(validator.isValid(valid.copy(placeSystemName = " "))) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt index 4ee57875..4eb90263 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedFilteringManagerTest.kt @@ -36,12 +36,12 @@ class EmbeddedFilteringManagerTest { } @Test - fun `place comparison trims whitespace on both sides`() { + fun `place comparison takes the padding as part of the name`() { val embedded = embeddedInApp() val result = manager.filterEmbeddedInAppsByPlace(listOf(embedded), " main-screen-top ") - assertEquals(listOf(embedded), result) + assertTrue(result.isEmpty()) } @Test