diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c4086e6..63f954d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Pending changes - [#268](https://github.com/bumble-tech/appyx/pull/268) – **Fixed**: `PermanentChild` now does not crash in UI tests with `ComposeTestRule`. +- [#268](https://github.com/bumble-tech/appyx/pull/276) – **Fixed**: Back press handlers order is fixed for RIBs-Appyx integration. - [#272](https://github.com/bumble-tech/appyx/pull/272) – **Changed**: `attachWorkflow` renamed to `attachChild`. `executeWorkflow` renamed to `executeAction`. - [#272](https://github.com/bumble-tech/appyx/pull/272) – **Added**: `NodeReadyObserver` plugin to observe when the `Node` is ready diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e46fe27d3..e346e0a80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ mvicore-android = { module = "com.github.badoo.mvicore:mvicore-android", version mvicore-binder = { module = "com.github.badoo.mvicore:binder", version.ref = "mvicore" } ribs-base = { module = "com.github.badoo.RIBs:rib-base", version.ref = "ribs" } ribs-base-test = { module = "com.github.badoo.RIBs:rib-base-test", version.ref = "ribs" } +ribs-base-test-activity = { module = "com.github.badoo.RIBs:rib-base-test-activity", version.ref = "ribs" } ribs-base-test-rx2 = { module = "com.github.badoo.RIBs:rib-base-test-rx2", version.ref = "ribs" } ribs-compose = { module = "com.github.badoo.RIBs:rib-compose", version.ref = "ribs" } ribs-rx = { module = "com.github.badoo.RIBs:rib-rx2", version.ref = "ribs" } diff --git a/libraries/interop-ribs/build.gradle.kts b/libraries/interop-ribs/build.gradle.kts index cb49fc9e2..26b8c3404 100644 --- a/libraries/interop-ribs/build.gradle.kts +++ b/libraries/interop-ribs/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.library") id("kotlin-android") + id("kotlin-parcelize") id("appyx-publish-android") id("appyx-lint") id("appyx-detekt") @@ -30,6 +31,14 @@ dependencies { implementation(libs.androidx.core) implementation(libs.androidx.lifecycle.java8) + implementation(libs.androidx.activity.compose) implementation(libs.compose.ui.ui) implementation(libs.ribs.compose) + + androidTestImplementation(libs.androidx.activity.compose) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.compose.foundation.layout) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.ribs.base.test.activity) } diff --git a/libraries/interop-ribs/src/androidTest/AndroidManifest.xml b/libraries/interop-ribs/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..875944729 --- /dev/null +++ b/libraries/interop-ribs/src/androidTest/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/AppyxRibsInteropActivity.kt b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/AppyxRibsInteropActivity.kt new file mode 100644 index 000000000..9e334a717 --- /dev/null +++ b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/AppyxRibsInteropActivity.kt @@ -0,0 +1,20 @@ +package com.bumble.appyx.interop.ribs + +import android.os.Bundle +import android.view.ViewGroup +import com.badoo.ribs.core.Rib +import com.badoo.ribs.core.modality.BuildContext + +class AppyxRibsInteropActivity : InteropActivity() { + + lateinit var ribsNode: RibsNode + + override val rootViewGroup: ViewGroup + get() = findViewById(android.R.id.content) + + override fun createRib(savedInstanceState: Bundle?): Rib = + RibsNodeBuilder() + .build(BuildContext.root(savedInstanceState), appyxIntegrationPoint) + .also { ribsNode = it } + +} diff --git a/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/InteropNodeImplTest.kt b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/InteropNodeImplTest.kt new file mode 100644 index 000000000..ae855bc8e --- /dev/null +++ b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/InteropNodeImplTest.kt @@ -0,0 +1,35 @@ +package com.bumble.appyx.interop.ribs + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.espresso.Espresso +import org.junit.Rule +import org.junit.Test + +class InteropNodeImplTest { + + @get:Rule + val rule = createAndroidComposeRule(AppyxRibsInteropActivity::class.java) + + @Test + fun appyx_back_press_is_handled_before_rib_parent() { + // push a new child into the backstack + var newChild = "" + var oldChild = "" + rule.activityRule.scenario.onActivity { + oldChild = it.ribsNode.current() + newChild = it.ribsNode.push() + } + + Espresso.pressBack() + + // the back press is swollen by the child node, newChild is still displayed + rule.onNodeWithTag(newChild).assertExists() + + Espresso.pressBack() + + // the back press is handled by RIBs now and the old child is displayed + rule.onNodeWithTag(oldChild).assertExists() + } + +} diff --git a/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/RibsNode.kt b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/RibsNode.kt new file mode 100644 index 000000000..b16a61fd1 --- /dev/null +++ b/libraries/interop-ribs/src/androidTest/kotlin/com/bumble/appyx/interop/ribs/RibsNode.kt @@ -0,0 +1,112 @@ +package com.bumble.appyx.interop.ribs + +import android.os.Parcelable +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.lifecycle.Lifecycle +import com.badoo.ribs.builder.Builder +import com.badoo.ribs.core.Node +import com.badoo.ribs.core.modality.BuildParams +import com.badoo.ribs.core.plugin.Plugin +import com.badoo.ribs.core.view.AndroidRibView2 +import com.badoo.ribs.core.view.ViewFactory +import com.badoo.ribs.routing.Routing +import com.badoo.ribs.routing.resolution.ChildResolution +import com.badoo.ribs.routing.resolution.Resolution +import com.badoo.ribs.routing.router.Router +import com.badoo.ribs.routing.source.backstack.BackStack +import com.badoo.ribs.routing.source.backstack.operation.push +import com.bumble.appyx.core.integrationpoint.IntegrationPoint +import com.bumble.appyx.core.modality.BuildContext +import kotlinx.parcelize.Parcelize +import java.util.UUID + +class RibsNode( + buildParams: BuildParams<*>, + private val backStack: BackStack, + plugins: List, +) : Node( + buildParams = buildParams, + viewFactory = View.Factory(), + plugins = plugins, +) { + + fun current(): String = + backStack.activeConfiguration.id + + fun push(): String { + val id = UUID.randomUUID().toString() + backStack.push(RibsNodeRouter.Configuration(id)) + return id + } + + class View( + androidView: ViewGroup, + lifecycle: Lifecycle, + ) : AndroidRibView2( + androidView, + lifecycle + ) { + class Factory : ViewFactory { + override fun invoke(context: ViewFactory.Context): View = + View(FrameLayout(context.parent.context), context.lifecycle) + } + } +} + +class RibsNodeBuilder : Builder() { + override fun build(buildParams: BuildParams): RibsNode { + val backStack = BackStack( + RibsNodeRouter.Configuration(UUID.randomUUID().toString()), + buildParams + ) + val router = RibsNodeRouter(buildParams, buildParams.payload, backStack) + return RibsNode(buildParams, backStack, listOf(router)) + } +} + +class RibsNodeRouter( + buildParams: BuildParams<*>, + private val integrationPoint: IntegrationPoint, + backStack: BackStack, +) : Router( + buildParams = buildParams, + routingSource = backStack +) { + @Parcelize + data class Configuration(val id: String) : Parcelable + + override fun resolve(routing: Routing): Resolution = + ChildResolution.child { + InteropBuilder( + nodeFactory = { buildContext -> AppyxNode(buildContext, routing.configuration.id) }, + integrationPoint = integrationPoint, + ).build(it) + } +} + +class AppyxNode( + buildContext: BuildContext, + private val s: String, +) : com.bumble.appyx.core.node.Node( + buildContext, +) { + var shouldInterceptBackPress by mutableStateOf(true) + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier.testTag(s)) { + BackHandler(shouldInterceptBackPress) { + shouldInterceptBackPress = false + } + } + } +} diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBackPressHandler.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBackPressHandler.kt new file mode 100644 index 000000000..5dde59127 --- /dev/null +++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBackPressHandler.kt @@ -0,0 +1,45 @@ +package com.bumble.appyx.interop.ribs + +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.lifecycle.Lifecycle +import com.badoo.ribs.core.plugin.BackPressHandler +import com.badoo.ribs.core.plugin.NodeLifecycleAware + +/** + * When we put Appyx into RIBs, we have the following invocation order of back handlers: + * 1. All active RIBs handlers using RIBs back press API. + * 2. All active Appyx handlers using AndroidX back press API. + * + * The reason is RIBs integration point implementation + * where `super.onBackPressed()` is invoked after `Node.handleBackPress()`. + * + * Let's collect all Appyx back press handlers into a separate [OnBackPressedDispatcher] + * and use it for `handleBackPress()`. + */ +internal class InteropBackPressHandler : + BackPressHandler, + OnBackPressedDispatcherOwner, + NodeLifecycleAware { + + private val dispatcher = OnBackPressedDispatcher() + private lateinit var lifecycle: Lifecycle + + override fun onCreate(nodeLifecycle: Lifecycle) { + lifecycle = nodeLifecycle + } + + override fun handleBackPress(): Boolean = + if (dispatcher.hasEnabledCallbacks()) { + dispatcher.onBackPressed() + true + } else { + false + } + + override fun getLifecycle(): Lifecycle = lifecycle + + override fun getOnBackPressedDispatcher(): OnBackPressedDispatcher = + dispatcher + +} diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt index 73ad307e4..954da28d7 100644 --- a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt +++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropBuilder.kt @@ -2,12 +2,12 @@ package com.bumble.appyx.interop.ribs import com.badoo.ribs.builder.SimpleBuilder import com.badoo.ribs.core.modality.BuildParams -import com.bumble.appyx.interop.ribs.InteropNodeImpl.Companion.InteropNodeKey import com.bumble.appyx.core.integration.NodeFactory import com.bumble.appyx.core.integrationpoint.IntegrationPoint import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.build +import com.bumble.appyx.interop.ribs.InteropNodeImpl.Companion.InteropNodeKey class InteropBuilder( private val nodeFactory: NodeFactory, diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt index 9d7a85d0a..52e20ec8c 100644 --- a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt +++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropNode.kt @@ -1,6 +1,7 @@ package com.bumble.appyx.interop.ribs import android.os.Bundle +import androidx.activity.OnBackPressedDispatcherOwner import androidx.core.os.bundleOf import androidx.lifecycle.LifecycleEventObserver import com.badoo.ribs.core.Rib @@ -22,6 +23,7 @@ interface InteropNode : Rib { internal class InteropNodeImpl( buildParams: BuildParams<*>, override val appyxNode: N, + private val backPressHandler: InteropBackPressHandler = InteropBackPressHandler(), ) : com.badoo.ribs.core.Node( buildParams = buildParams, viewFactory = buildParams.getOrDefault( @@ -29,10 +31,13 @@ internal class InteropNodeImpl( viewFactory = Factory().invoke( object : InteropView.Dependency { override val appyxNode: N = appyxNode + override val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner = + backPressHandler } ) ), - ).viewFactory + ).viewFactory, + plugins = listOf(backPressHandler), ), InteropNode { private val observer = LifecycleEventObserver { source, _ -> diff --git a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt index 3bf226834..115b2a6f4 100644 --- a/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt +++ b/libraries/interop-ribs/src/main/kotlin/com/bumble/appyx/interop/ribs/InteropView.kt @@ -1,7 +1,10 @@ package com.bumble.appyx.interop.ribs import android.content.Context +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.lifecycle.Lifecycle import com.badoo.ribs.compose.ComposeRibView import com.badoo.ribs.compose.ComposeView @@ -15,6 +18,7 @@ interface InteropView : RibView { interface Dependency { val appyxNode: N + val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner } } @@ -22,11 +26,16 @@ internal class InteropViewImpl private constructor( override val context: Context, lifecycle: Lifecycle, private val appyxNode: Node, + private val onBackPressedDispatcherOwner: OnBackPressedDispatcherOwner, ) : InteropView, ComposeRibView(context, lifecycle) { override val composable: ComposeView get() = @Composable { - appyxNode.Compose() + CompositionLocalProvider( + LocalOnBackPressedDispatcherOwner provides onBackPressedDispatcherOwner, + ) { + appyxNode.Compose() + } } class Factory : ViewFactoryBuilder, InteropView> { @@ -36,6 +45,7 @@ internal class InteropViewImpl private constructor( context = it.parent.context, lifecycle = it.lifecycle, appyxNode = deps.appyxNode, + onBackPressedDispatcherOwner = deps.onBackPressedDispatcherOwner, ) } }