Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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,
Comment thread
Vailence marked this conversation as resolved.
onLoad: () -> Unit = {},
onFail: () -> Unit = {},
placeholder: (@Composable () -> Unit)? = null,
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Long?>(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<String> = ShadowLog.getLogs()
.filter { log -> log.msg?.contains("was given timeoutMs=") == true }
.map { log -> log.msg }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -40,7 +41,10 @@ internal class EmbeddedBlocksRegistryImpl(
private val scopeProvider: () -> CoroutineScope = { Mindbox.mindboxScope },
) : EmbeddedBlocksRegistry {

private val handlesByPlace = mutableMapOf<String, MutableList<EmbeddedBlockHandle>>()
// 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<String, MutableList<WeakReference<EmbeddedBlockHandle>>>()
private val resolvingPlaces = mutableSetOf<String>()

private val reResolveQueuedPlaces = mutableMapOf<String, InAppEventType?>()
Expand Down Expand Up @@ -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)
}
}
},
Expand All @@ -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<EmbeddedBlockHandle> {
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
}
Expand Down Expand Up @@ -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) }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading