From 7a92cdf5b78bbba3cc8d224dec395fe2e9389851 Mon Sep 17 00:00:00 2001 From: sozinov Date: Tue, 1 Sep 2026 17:31:20 +0300 Subject: [PATCH 1/2] MOBILE-413:Measure the content the block shows, whatever the host does --- .../embedded/MindboxEmbeddedBlockView.kt | 47 ++++ ...ndboxEmbeddedBlockViewContentLayoutTest.kt | 253 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt 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 cd929169..81b5bdf4 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 @@ -2,6 +2,7 @@ package cloud.mindbox.mobile_sdk.embedded import android.content.Context import android.graphics.Color +import android.graphics.Rect import android.os.Handler import android.os.Looper import android.util.AttributeSet @@ -103,6 +104,8 @@ public class MindboxEmbeddedBlockView internal constructor( private val defaultPlaceholder by lazy { EmbeddedBlockDefaultViews.placeholder(context) } private val mainHandler = Handler(Looper.getMainLooper()) + private val contentFrame = Rect() + private enum class BlockEvent { LOADED, FAILED } private var state: EmbeddedBlockState = EmbeddedBlockState.Loading @@ -235,8 +238,20 @@ public class MindboxEmbeddedBlockView internal constructor( private val hasCustomErrorView: Boolean get() = errorView != null + override fun onLayout( + changed: Boolean, + left: Int, + top: Int, + right: Int, + bottom: Int + ) { + withContentFrame(right - left, bottom - top) { frame -> shownContent?.measureTo(frame) } + super.onLayout(changed, left, top, right, bottom) + } + override fun onAttachedToWindow() { super.onAttachedToWindow() + shownContent?.let(::placeContentInFrame) observeHostDestruction() isWindowVisible = windowVisibility == VISIBLE updateContentActivity() @@ -415,6 +430,38 @@ public class MindboxEmbeddedBlockView internal constructor( shownContent = content (content.parent as? ViewGroup)?.removeView(content) addView(content, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + placeContentInFrame(content) + } + + private fun placeContentInFrame(content: View) { + withContentFrame(width, height) { frame -> content.placeIn(frame) } + } + + private inline fun withContentFrame(blockWidth: Int, blockHeight: Int, action: (Rect) -> Unit) { + contentFrame.set( + paddingLeft, + paddingTop, + blockWidth - paddingRight, + blockHeight - paddingBottom, + ) + if (contentFrame.isEmpty) return + action(contentFrame) + } + + private fun View.placeIn(frame: Rect) { + if (left == frame.left && top == frame.top && right == frame.right && bottom == frame.bottom) { + return + } + measureTo(frame) + layout(frame.left, frame.top, frame.right, frame.bottom) + } + + private fun View.measureTo(frame: Rect) { + if (measuredWidth == frame.width() && measuredHeight == frame.height()) return + measure( + MeasureSpec.makeMeasureSpec(frame.width(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(frame.height(), MeasureSpec.EXACTLY), + ) } private fun clearContent() { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt new file mode 100644 index 00000000..517c8e59 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt @@ -0,0 +1,253 @@ +package cloud.mindbox.mobile_sdk.embedded + +import android.app.Activity +import android.content.Context +import android.os.Looper +import android.view.View +import android.view.View.MeasureSpec +import android.widget.FrameLayout +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.models.InAppStub +import org.junit.Assert.assertEquals +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 MindboxEmbeddedBlockViewContentLayoutTest { + + 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 + } + + /** + * A host that answers a layout request the way Compose interop can: `AndroidViewsHandler` + * never passes it up to `ViewRootImpl`, it translates it into a Compose remeasure request — + * and that one is dropped while the node is being measured or waits in a LazyColumn's reuse + * pool. Then no pass ever comes, which is what the block has to survive. + */ + private class LayoutRequestSwallowingHost(context: Context) : FrameLayout(context) { + override fun requestLayout() = Unit + } + + /** A page that keeps loading until the test says it rendered — as one does off screen. */ + private class SilentProvider(context: Activity) : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View = View(context) + + override fun start() = Unit + + override fun pause() = Unit + + override fun release() = Unit + + fun reportReady() { + onStateChange?.invoke(EmbeddedBlockState.Ready) + } + } + + private class ReadyProvider(context: Activity) : EmbeddedContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View = View(context) + + override fun start() { + onStateChange?.invoke(EmbeddedBlockState.Ready) + } + + 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( + provider: () -> EmbeddedContentProvider = { ReadyProvider(activity) }, + ): MindboxEmbeddedBlockView = + MindboxEmbeddedBlockView( + activity, + null, + "main-screen-top", + contentController = EmbeddedBlockContentController( + placeSystemName = "main-screen-top", + providerFactory = { _, _ -> provider().also { lastProvider = it } }, + blocksRegistry = { blocksRegistry }, + ), + ) + + private fun idle() { + shadowOf(Looper.getMainLooper()).idle() + } + + /** + * Attaches the block and lays the host out once — the only layout pass the test grants. What + * happens to content that arrives afterwards must not depend on a second one. + */ + private fun attachAndLayOut(view: MindboxEmbeddedBlockView, padding: Int = 0): FrameLayout { + val host = LayoutRequestSwallowingHost(activity).apply { + addView(view, FRAME_WIDTH, FRAME_HEIGHT) + } + view.setPadding(padding, padding, padding, padding) + activity.setContentView(host) + idle() + dispatchWindowVisibility(view, View.VISIBLE) + idle() + layOutOnce(host) + return host + } + + private fun layOutOnce(host: FrameLayout) { + host.measure( + MeasureSpec.makeMeasureSpec(FRAME_WIDTH, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(FRAME_HEIGHT, MeasureSpec.EXACTLY), + ) + host.layout(0, 0, FRAME_WIDTH, FRAME_HEIGHT) + } + + @Test + fun `content arriving after the block was laid out fills the frame without another host pass`() { + val view = buildView() + attachAndLayOut(view) + + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + + val content = lastProvider?.contentView + assertEquals(FRAME_WIDTH, content?.width) + assertEquals(FRAME_HEIGHT, content?.height) + } + + @Test + fun `content laid out by the block itself honors the padding of the frame`() { + val view = buildView() + attachAndLayOut(view, padding = PADDING) + + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + + val content = lastProvider?.contentView + assertEquals(FRAME_WIDTH - 2 * PADDING, content?.width) + assertEquals(FRAME_HEIGHT - 2 * PADDING, content?.height) + assertEquals(PADDING, content?.left) + assertEquals(PADDING, content?.top) + } + + /** + * The corner case MOBILE-413 was actually caught on: a LazyColumn deactivates the item, so the + * block leaves the window keeping the frame it had, and the page reports its content right + * then. The reactivated node comes back without a remeasure of its own — nothing would ever + * measure the content if the block waited for a pass. + */ + @Test + fun `content arriving while the block is out of the window still fills the frame it kept`() { + val view = buildView(provider = { SilentProvider(activity) }) + val host = attachAndLayOut(view) + // The page starts loading while the block is on screen… + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + // …the item is deactivated, so the block leaves the window keeping its frame… + host.removeView(view) + idle() + + // …and only then the page reports what it rendered. + (lastProvider as SilentProvider).reportReady() + idle() + + val content = lastProvider?.contentView + assertEquals(FRAME_WIDTH, content?.width) + assertEquals(FRAME_HEIGHT, content?.height) + } + + /** + * The sequence MOBILE-413 was caught on: the list measures the item in one frame and places it + * in a later one, and the page reports its content in between. A child added after the measure + * has no measured size — `FrameLayout` would place it at 0×0 and eat its layout request along + * the way, so the block has to measure it to the frame it is being laid out into. + */ + @Test + fun `content added between the measure and the layout of the block is measured too`() { + val view = buildView(provider = { SilentProvider(activity) }) + val host = LayoutRequestSwallowingHost(activity).apply { + addView(view, FRAME_WIDTH, FRAME_HEIGHT) + } + activity.setContentView(host) + idle() + dispatchWindowVisibility(view, View.VISIBLE) + idle() + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + + // Measured, but not placed yet — the state the list leaves the item in between its own + // measure and layout phases. + view.layout(0, 0, 0, 0) + host.measure( + MeasureSpec.makeMeasureSpec(FRAME_WIDTH, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(FRAME_HEIGHT, MeasureSpec.EXACTLY), + ) + (lastProvider as SilentProvider).reportReady() + idle() + host.layout(0, 0, FRAME_WIDTH, FRAME_HEIGHT) + + val content = lastProvider?.contentView + assertEquals(FRAME_WIDTH, content?.width) + assertEquals(FRAME_HEIGHT, content?.height) + } + + @Test + fun `content added without a frame is laid out when the block returns to the window`() { + val view = buildView() + val host = attachAndLayOut(view) + val content = View(activity) + + // Out of the window and with the frame dropped, the way a fresh block starts out. + host.removeView(view) + view.layout(0, 0, 0, 0) + idle() + view.setPlaceholderView(content) + idle() + assertEquals(0, content.width) + + // Back in the window with the frame it had — and no pass from the host. + view.layout(0, 0, FRAME_WIDTH, FRAME_HEIGHT) + host.addView(view, FRAME_WIDTH, FRAME_HEIGHT) + idle() + + assertEquals(FRAME_WIDTH, content.width) + assertEquals(FRAME_HEIGHT, content.height) + } + + @Test + fun `a placeholder swapped in after the layout fills the frame too`() { + val view = buildView() + attachAndLayOut(view) + val placeholder = View(activity) + + view.setPlaceholderView(placeholder) + idle() + + assertEquals(FRAME_WIDTH, placeholder.width) + assertEquals(FRAME_HEIGHT, placeholder.height) + } + + private fun embeddedContent(): InAppType.Embedded = InAppStub.getEmbedded() + + private companion object { + const val FRAME_WIDTH = 500 + const val FRAME_HEIGHT = 300 + const val PADDING = 20 + } +} From f5b79533eacb3c02a04361d93a2fe6362cbe32cc Mon Sep 17 00:00:00 2001 From: sozinov Date: Tue, 1 Sep 2026 18:07:08 +0300 Subject: [PATCH 2/2] MOBILE-413:follow review --- .../embedded/MindboxEmbeddedBlockView.kt | 20 +++++++++++++++++ ...ndboxEmbeddedBlockViewContentLayoutTest.kt | 22 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) 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 81b5bdf4..fa76b310 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 @@ -249,6 +249,26 @@ public class MindboxEmbeddedBlockView internal constructor( super.onLayout(changed, left, top, right, bottom) } + override fun setPadding( + left: Int, + top: Int, + right: Int, + bottom: Int + ) { + super.setPadding(left, top, right, bottom) + shownContent?.let(::placeContentInFrame) + } + + override fun setPaddingRelative( + start: Int, + top: Int, + end: Int, + bottom: Int + ) { + super.setPaddingRelative(start, top, end, bottom) + shownContent?.let(::placeContentInFrame) + } + override fun onAttachedToWindow() { super.onAttachedToWindow() shownContent?.let(::placeContentInFrame) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt index 517c8e59..2ec2e67e 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockViewContentLayoutTest.kt @@ -221,8 +221,11 @@ class MindboxEmbeddedBlockViewContentLayoutTest { idle() assertEquals(0, content.width) - // Back in the window with the frame it had — and no pass from the host. + // The block has its frame back while the content is still unmeasured inside it — the state + // a reactivated node comes back in. Restoring the frame is a layout of its own, so the + // content is put back to nothing after it: what has to heal this is the reattachment. view.layout(0, 0, FRAME_WIDTH, FRAME_HEIGHT) + content.layout(0, 0, 0, 0) host.addView(view, FRAME_WIDTH, FRAME_HEIGHT) idle() @@ -230,6 +233,23 @@ class MindboxEmbeddedBlockViewContentLayoutTest { assertEquals(FRAME_HEIGHT, content.height) } + @Test + fun `padding given after the content is shown moves it without another host pass`() { + val view = buildView() + attachAndLayOut(view) + blocksRegistry.lastHandle?.onContentResolved(embeddedContent()) + idle() + val content = lastProvider?.contentView + + view.setPadding(PADDING, PADDING, PADDING, PADDING) + idle() + + assertEquals(FRAME_WIDTH - 2 * PADDING, content?.width) + assertEquals(FRAME_HEIGHT - 2 * PADDING, content?.height) + assertEquals(PADDING, content?.left) + assertEquals(PADDING, content?.top) + } + @Test fun `a placeholder swapped in after the layout fills the frame too`() { val view = buildView()