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
5 changes: 4 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ ktlint_standard_argument-list-wrapping = disabled
ktlint_standard_wrapping = disabled

# Throw error when class has single class and file name and class name different
ktlint_standard_filename = disabled
ktlint_standard_filename = disabled

# Composable functions are PascalCase by convention
ktlint_function_naming_ignore_when_annotated_with = Composable
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
kover(project(':mindbox-huawei-starter'))
kover(project(':mindbox-rustore-starter'))
kover(project(':mindbox-sdk-starter-core'))
kover(project(':mindbox-embedded-compose'))
}

tasks.register('clean', Delete) {
Expand Down
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ constraint_layout = "2.1.4"
threetenapb = "1.4.6"
glide = "4.15.1"
app_compat = "1.7.1"
compose_bom = "2024.06.00"
compose_compiler = "1.5.8"

junit = "4.13.2"
androidx_junit = "1.1.3"
Expand Down Expand Up @@ -75,6 +77,11 @@ androidTest = [
[libraries]
kotlin_stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
androidx_core_ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx_core_ktx" }
compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
compose_ui = { group = "androidx.compose.ui", name = "ui" }
compose_foundation_layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
compose_ui_test_junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose_ui_test_manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx_annotations = { group = "androidx.annotation", name = "annotation", version.ref = "androidx_annotations" }
firebase_bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase_bom" }
firebase_messaging = { group = "com.google.firebase", name = "firebase-messaging" }
Expand All @@ -91,6 +98,7 @@ room_ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room_compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
work_manager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work_manager" }
androidx_lifecycle = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx_lifecycle" }
androidx_lifecycle_runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "androidx_lifecycle" }
androidx_startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx_startup" }
hms_push = { group = "com.huawei.hms", name = "push", version.ref = "hms_push" }
hms_ads_identifier = { group = "com.huawei.hms", name = "ads-identifier", version.ref = "hms_ads_identifier" }
Expand Down
39 changes: 39 additions & 0 deletions mindbox-embedded-compose/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
apply from: "../modulesCommon.gradle"

android {
namespace 'cloud.mindbox.mobile_sdk.embedded.compose'

buildFeatures {
buildConfig = true
compose true
}

composeOptions {
kotlinCompilerExtensionVersion libs.versions.compose.compiler.get()
}

testOptions {
unitTests.includeAndroidResources = true
}
}

def useLocalMindboxCommon = providers.gradleProperty("USE_LOCAL_MINDBOX_COMMON").map { it.toBoolean() }.getOrElse(false)

dependencies {
api project(path: ':sdk')
if (useLocalMindboxCommon) {
compileOnly project(path: ':mindbox-common')
} else {
compileOnly "cloud.mindbox:mindbox-common:$SDK_VERSION_NAME"
}

implementation platform(libs.compose.bom)
implementation libs.compose.ui
implementation libs.compose.foundation.layout

// Test dependencies
testImplementation libs.bundles.test
testImplementation platform(libs.compose.bom)
testImplementation libs.compose.ui.test.junit4
testImplementation libs.compose.ui.test.manifest
}
4 changes: 4 additions & 0 deletions mindbox-embedded-compose/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ARTIFACT_ID=mindbox-embedded-compose
ARTIFACT_NAME=Mindbox Embedded Blocks Compose
MIN_SDK_VERSION=21
android.disableAutomaticComponentCreation=true
1 change: 1 addition & 0 deletions mindbox-embedded-compose/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# No module-specific rules: the module is a thin Compose wrapper over :sdk.
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package cloud.mindbox.mobile_sdk.embedded.compose

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView

/**
* An embedded Mindbox block as a composable.
*
* The caller marks a *place* by its [placeSystemName] — what the place shows is decided by the
* mobile config, the app never learns it. **The caller owns the size**: give the block an
* explicit height (e.g. `Modifier.height(120.dp)`) — the block is a fixed frame and the content
* adapts to it, so the layout never jumps. While the content loads the frame shows a placeholder
* (the SDK's default placeholder or the [placeholder] slot); on failure — the [error] slot, if set.
*
* The behavior mirrors the View one and belongs to the block itself: it is visible while
* loading and showing content, and collapses to zero height when the place ends up without
* content — unless the [error] slot is set: a custom error view is a request to keep the
* place, so the block stays and shows it. The callbacks only report the outcome.
*
* ```kotlin
* MindboxEmbeddedBlock(
* placeSystemName = "main-screen-top",
* modifier = Modifier.height(120.dp),
* onLoad = { /* the block is shown */ },
* )
* ```
*
* @param placeSystemName The place identifier matched against the config's `inlineBlocks`
* section. Changing it recreates the block for the new place. Blocks with the same name work
* independently, each with its own content.
* @param onLoad The block is shown and visible. Main thread.
* @param onFail The place ends up without content — the load failed or timed out, or the
* config had nothing to put here. The block collapsed, or — if the [error] slot is set —
* stayed in place showing it. Not necessarily a breakage: an empty place is a normal outcome.
* Main thread.
* @param placeholder Replaces the SDK's default loading placeholder. Fills the whole block frame.
* @param error The view for a place without content. Setting it also keeps the block visible
* instead of the default collapse. Fills the whole block frame.
*/
@OptIn(InternalMindboxApi::class)
@Composable
public fun MindboxEmbeddedBlock(
placeSystemName: String,
modifier: Modifier = Modifier,
onLoad: () -> Unit = {},
onFail: () -> Unit = {},
placeholder: (@Composable () -> Unit)? = null,
error: (@Composable () -> Unit)? = null,
) {
val currentOnLoad by rememberUpdatedState(onLoad)
val currentOnFail by rememberUpdatedState(onFail)
val currentPlaceholder by rememberUpdatedState(placeholder)
val currentError by rememberUpdatedState(error)

val context = LocalContext.current

key(placeSystemName) {
var isCollapsed by remember { mutableStateOf(false) }

val placeholderHost = remember(context) {
lazy(LazyThreadSafetyMode.NONE) {
ComposeView(context).apply { setContent { currentPlaceholder?.invoke() } }
}
}
val errorHost = remember(context) {
lazy(LazyThreadSafetyMode.NONE) {
ComposeView(context).apply { setContent { currentError?.invoke() } }
}
}

AndroidView(
modifier = (if (isCollapsed) Modifier.height(0.dp).then(modifier) else modifier).fillMaxWidth(),
factory = { viewContext ->
MindboxEmbeddedBlockView(viewContext, placeSystemName).apply {
setVisibilityObserver { isVisible -> isCollapsed = !isVisible }
setListener(
object : MindboxEmbeddedBlockListener {
override fun onLoad(view: MindboxEmbeddedBlockView) {
currentOnLoad()
}

override fun onFail(view: MindboxEmbeddedBlockView) {
currentOnFail()
}
},
)
}
},
update = { view ->
view.setPlaceholderView(placeholder?.let { placeholderHost.value })
view.setErrorView(error?.let { errorHost.value })
},
onRelease = { view -> view.release() },
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package cloud.mindbox.mobile_sdk.embedded.compose

import android.os.Looper
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf

@RunWith(RobolectricTestRunner::class)
class MindboxEmbeddedBlockTest {

@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()

/** Puts the host window on screen so the block inside starts its content. */
private fun settle() {
compose.waitForIdle()
dispatchWindowVisibility(compose.activity.window.decorView, View.VISIBLE)
shadowOf(Looper.getMainLooper()).idle()
compose.waitForIdle()
}

@Test
fun `the block keeps its frame while the config has not arrived`() {
// Nothing is initialized in this test process — the case a host hits when it composes a
// screen before the SDK finished starting up. The block holds the height it was given
// and stays silent: no config yet is not an empty place.
val events = mutableListOf<String>()

compose.setContent {
MindboxEmbeddedBlock(
placeSystemName = "main-screen-top",
modifier = Modifier
.height(120.dp)
.testTag("block"),
onLoad = { events.add("load") },
onFail = { events.add("fail") },
)
}
settle()

compose.onNodeWithTag("block").assertExists()
compose.onNodeWithTag("block").assertHeightIsEqualTo(120.dp)
assertTrue(events.isEmpty())
}

@Test
fun `a place with no name collapses the block to nothing`() {
// A GONE child cannot shrink a Compose modifier by itself — the wrapper has to collapse
// the frame, or the host layout keeps a hole where the block used to be.
val events = mutableListOf<String>()

compose.setContent {
MindboxEmbeddedBlock(
placeSystemName = "",
modifier = Modifier
.height(120.dp)
.testTag("block"),
onFail = { events.add("fail") },
)
}
settle()

compose.onNodeWithTag("block").assertHeightIsEqualTo(0.dp)
assertTrue(events.contains("fail"))
}

@Test
fun `custom slots are accepted and the block still resolves`() {
// Slot rendering itself is covered at the view level (setPlaceholderView/setErrorView);
// here the wiring must simply survive slots being present.
val events = mutableListOf<String>()

compose.setContent {
MindboxEmbeddedBlock(
placeSystemName = "main-screen-top",
modifier = Modifier.height(120.dp),
onFail = { events.add("fail") },
placeholder = { Box(Modifier.fillMaxSize().testTag("custom-placeholder")) },
error = { Box(Modifier.fillMaxSize().testTag("custom-error")) },
)
}
settle()

compose.onNodeWithTag("custom-placeholder").assertExists()
assertTrue(events.isEmpty())
}

@Test
fun `a slot that appears after the first composition still reaches the block`() {
// The factory runs once. A caller that decides on its slots later — after a flag loads,
// after a theme resolves — would otherwise never get them installed at all.
val withPlaceholder = mutableStateOf(false)

compose.setContent {
MindboxEmbeddedBlock(
placeSystemName = "main-screen-top",
modifier = Modifier.height(120.dp),
placeholder = if (withPlaceholder.value) {
{ Box(Modifier.fillMaxSize().testTag("custom-placeholder")) }
} else {
null
},
)
}
settle()
compose.onNodeWithTag("custom-placeholder").assertDoesNotExist()

compose.runOnUiThread { withPlaceholder.value = true }
settle()

compose.onNodeWithTag("custom-placeholder").assertExists()
}

@Test
fun `default callbacks and slots are optional`() {
compose.setContent {
MindboxEmbeddedBlock(
placeSystemName = "main-screen-top",
modifier = Modifier.height(120.dp),
)
}
settle()
}

@Test
fun `leaving the composition frees the block`() {
// AndroidView's onRelease is the only teardown signal a composable gets — without it
// every scroll past the block would leak a WebView.
val shown = mutableStateOf(true)

compose.setContent {
if (shown.value) {
MindboxEmbeddedBlock(
placeSystemName = "main-screen-top",
modifier = Modifier.height(120.dp).testTag("block"),
)
}
}
settle()
compose.onNodeWithTag("block").assertExists()

compose.runOnUiThread { shown.value = false }
settle()

compose.onNodeWithTag("block").assertDoesNotExist()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package cloud.mindbox.mobile_sdk.embedded.compose

import android.view.View
import org.robolectric.util.ReflectionHelpers
import org.robolectric.util.ReflectionHelpers.ClassParameter

/**
* Robolectric leaves every window at GONE, and the embedded block starts its content only once
* the window is visible — so without this a unit-tested block would never start. On a device the
* framework dispatches the same call down the view tree right after the attach.
*
* The entry point is hidden from the SDK stubs, so it is reached through Robolectric's own
* helper for framework internals.
*/
internal fun dispatchWindowVisibility(view: View, visibility: Int) {
ReflectionHelpers.callInstanceMethod<Unit>(
view,
"dispatchWindowVisibilityChanged",
ClassParameter.from(Int::class.javaPrimitiveType, visibility),
)
}
Loading