Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BackPressHandler plugin #32

Merged
merged 3 commits into from
Aug 11, 2022
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- [#39](https://github.com/bumble-tech/appyx/pull/39) – Added: Workflows implementation to support deeplinks
- [#47](https://github.com/bumble-tech/appyx/issues/47) – Updated: The 'customisations' module is now pure Java/Kotlin.
- [#32](https://github.com/bumble-tech/appyx/pull/32) – Added: `BackPressHandler` plugin that allows to control back press behaviour via `androidx.activity.OnBackPressedCallback`


## 1.0-alpha03
Expand Down
1 change: 1 addition & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@ dependencies {
androidTestImplementation libs.androidx.test.espresso.core
androidTestImplementation libs.androidx.test.junit
androidTestImplementation libs.compose.ui.test.junit4
androidTestImplementation project(':testing-ui')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package com.bumble.appyx.core.plugin

import android.app.Activity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.test.espresso.Espresso
import androidx.test.platform.app.InstrumentationRegistry
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.node.node
import com.bumble.appyx.debug.Appyx
import com.bumble.appyx.routingsource.backstack.BackStack
import com.bumble.appyx.routingsource.backstack.activeRouting
import com.bumble.appyx.routingsource.backstack.operation.push
import com.bumble.appyx.testing.ui.rules.AppyxTestRule
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.instanceOf
import org.junit.After
import org.junit.Rule
import org.junit.Test

class BackPressHandlerTest {

private var onBackPressedHandled = false
private var backPressHandler: BackPressHandler = object : BackPressHandler {
override val onBackPressedCallback: OnBackPressedCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onBackPressedHandled = true
}
}
}

@get:Rule
val rule = AppyxTestRule(launchActivity = false) { buildContext ->
TestParentNode(buildContext = buildContext, plugin = backPressHandler)
}

@After
fun after() {
Appyx.exceptionHandler = null
LachlanMcKee marked this conversation as resolved.
Show resolved Hide resolved
}

@Test
fun routing_handles_back_press_when_plugin_has_disabled_listener() {
rule.start()
runOnMainSync {
backPressHandler.onBackPressedCallback!!.isEnabled = false
rule.node.backStack.push(TestParentNode.Routing.ChildB)
}

Espresso.pressBack()
Espresso.onIdle()

assertThat(rule.node.backStack.activeRouting, equalTo(TestParentNode.Routing.ChildA))
assertThat(onBackPressedHandled, equalTo(false))
}

@Test
fun custom_plugin_handles_back_press_before_routing() {
rule.start()
runOnMainSync { rule.node.backStack.push(TestParentNode.Routing.ChildB) }

Espresso.pressBack()
Espresso.onIdle()

assertThat(rule.node.backStack.activeRouting, equalTo(TestParentNode.Routing.ChildB))
assertThat(onBackPressedHandled, equalTo(true))
}

@Test
fun activity_is_closed_when_nobody_can_handle_back_press() {
rule.start()
runOnMainSync {
backPressHandler.onBackPressedCallback!!.isEnabled = false
}

Espresso.pressBackUnconditionally()
Espresso.onIdle()

assertThat(rule.activityResult.resultCode, equalTo(Activity.RESULT_CANCELED))
}

@Test
fun reports_incorrect_handler() {
var exception: Exception? = null
Appyx.exceptionHandler = { exception = it }
backPressHandler = object : BackPressHandler {
override val onBackPressedCallback: OnBackPressedCallback =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
}
}

override val onBackPressedCallbackList: List<OnBackPressedCallback> = listOf(
onBackPressedCallback, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
}
}
)
}
rule.start()

Espresso.onIdle()

assertThat(exception, instanceOf(IllegalStateException::class.java))
}

private fun runOnMainSync(runner: Runnable) {
InstrumentationRegistry.getInstrumentation().runOnMainSync(runner)
}

class TestParentNode(
buildContext: BuildContext,
val backStack: BackStack<Routing> = BackStack(
initialElement = Routing.ChildA,
savedStateMap = null,
),
plugin: Plugin,
) : ParentNode<TestParentNode.Routing>(
buildContext = buildContext,
routingSource = backStack,
plugins = listOf(plugin),
) {

sealed class Routing {
object ChildA : Routing()
object ChildB : Routing()
}

override fun resolve(routing: Routing, buildContext: BuildContext) = when (routing) {
Routing.ChildA -> node(buildContext) {}
Routing.ChildB -> node(buildContext) {}
}

@Composable
override fun View(modifier: Modifier) {
Children(routingSource = backStack, modifier = modifier)
}
}

}
2 changes: 2 additions & 0 deletions core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
42 changes: 39 additions & 3 deletions core/src/main/java/com/bumble/appyx/core/node/Node.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.bumble.appyx.core.node

import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.annotation.CallSuper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
Expand All @@ -19,6 +22,7 @@ import com.bumble.appyx.core.lifecycle.NodeLifecycle
import com.bumble.appyx.core.lifecycle.NodeLifecycleImpl
import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.plugin.BackPressHandler
import com.bumble.appyx.core.plugin.Destroyable
import com.bumble.appyx.core.plugin.NodeAware
import com.bumble.appyx.core.plugin.NodeLifecycleAware
Expand Down Expand Up @@ -104,14 +108,34 @@ abstract class Node(
LocalNode provides this,
LocalLifecycleOwner provides this,
) {
DerivedSetup()
HandleBackPress()
View(modifier)
}
}

/** Derived classes can declare functional (non-ui) Composable blocks before [View()] is invoked. */
@Composable
protected open fun DerivedSetup() {
private fun HandleBackPress() {
val backPressHandlerPlugins = remember {
// reversed order because we want direct order, but onBackPressedDispatcher invokes them in reversed order
plugins.filterIsInstance<BackPressHandler>().reversed()
}
val dispatcher =
LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher ?: return
DisposableEffect(dispatcher) {
backPressHandlerPlugins.forEach { plugin ->
CherryPerry marked this conversation as resolved.
Show resolved Hide resolved
if (!plugin.isCorrect()) {
Appyx.reportException(
IllegalStateException("Plugin $plugin has implementation for both BackPressHandler properties, implement only one")
)
}
plugin.onBackPressedCallbackList.forEach { dispatcher.addCallback(it) }
}
onDispose {
backPressHandlerPlugins.forEach { plugin ->
plugin.onBackPressedCallbackList.forEach { it.remove() }
}
}
}
}

override fun getLifecycle(): Lifecycle =
Expand Down Expand Up @@ -175,4 +199,16 @@ abstract class Node(
private fun handleUpNavigationByPlugins(): Boolean =
plugins<UpNavigationHandler>().any { it.handleUpNavigation() }

companion object {

// BackPressHandler is correct when only one of its properties is implemented.
private fun BackPressHandler.isCorrect(): Boolean {
val listIsOverriddenOrPluginIgnored = onBackPressedCallback == null
val onlySingleCallbackIsOverridden = onBackPressedCallback != null &&
onBackPressedCallbackList.size == 1 &&
onBackPressedCallbackList[0] == onBackPressedCallback
return onlySingleCallbackIsOverridden || listIsOverriddenOrPluginIgnored
}

}
}
10 changes: 0 additions & 10 deletions core/src/main/java/com/bumble/appyx/core/node/ParentNode.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package com.bumble.appyx.core.node

import androidx.activity.compose.BackHandler
import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -265,14 +263,6 @@ abstract class ParentNode<Routing : Any>(
}
}

@Composable
final override fun DerivedSetup() {
val canHandleBackPress by routingSource.canHandleBackPress.collectAsState()
BackHandler(canHandleBackPress) {
routingSource.onBackPressed()
}
}

open fun onChildFinished(child: Node) {
// TODO warn unhandled child
}
Expand Down
26 changes: 23 additions & 3 deletions core/src/main/java/com/bumble/appyx/core/plugin/Plugins.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.bumble.appyx.core.plugin

import androidx.activity.OnBackPressedCallback
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.state.MutableSavedStateMap
Expand All @@ -21,16 +22,35 @@ interface NodeLifecycleAware : Plugin {
}

interface UpNavigationHandler : Plugin {
fun handleUpNavigation() = false
fun handleUpNavigation(): Boolean = false
}

fun interface Destroyable : Plugin {
fun destroy()
}

// TODO: It is not a plugin! Handled only in router!
/**
* Implementing class can handle back presses via [OnBackPressedCallback].
*
* Implement either [onBackPressedCallback] or [onBackPressedCallbackList], not both.
* In case if both implemented, [onBackPressedCallback] will be ignored.
* There is runtime check in [Node] to verify correctness.
*/
interface BackPressHandler : Plugin {
fun onBackPressed()

val onBackPressedCallback: OnBackPressedCallback? get() = null
CherryPerry marked this conversation as resolved.
Show resolved Hide resolved

/** It is impossible to combine multiple [OnBackPressedCallback] into the single one, they are not observable. */
val onBackPressedCallbackList: List<OnBackPressedCallback>
get() = listOfNotNull(onBackPressedCallback)

fun handleOnBackPressed(): Boolean =
onBackPressedCallbackList.any { callback ->
val isEnabled = callback.isEnabled
if (isEnabled) callback.handleOnBackPressed()
isEnabled
}

}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.bumble.appyx.core.routing

import androidx.activity.OnBackPressedCallback
import com.bumble.appyx.core.plugin.BackPressHandler
import com.bumble.appyx.core.plugin.Destroyable
import com.bumble.appyx.core.routing.backpresshandlerstrategies.BackPressHandlerStrategy
Expand All @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.EmptyCoroutineContext

/**
Expand All @@ -36,7 +38,7 @@ abstract class BaseRoutingSource<Routing, State>(
private val finalStates: Set<State>,
private val key: String = KEY_ROUTING_SOURCE,
savedStateMap: SavedStateMap?
) : RoutingSource<Routing, State>, Destroyable, BackPressHandler by backPressHandler {
) : RoutingSource<Routing, State>, Destroyable, BackPressHandler {

constructor(
backPressHandler: BackPressHandlerStrategy<Routing, State> = DontHandleBackPress(),
Expand Down Expand Up @@ -76,8 +78,18 @@ abstract class BaseRoutingSource<Routing, State>(
.stateIn(scope, SharingStarted.Eagerly, RoutingSourceAdapter.ScreenState())
}

override val canHandleBackPress: StateFlow<Boolean> by lazy(LazyThreadSafetyMode.NONE) {
backPressHandler.canHandleBackPress
override val onBackPressedCallback: OnBackPressedCallback by lazy {
val callback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
backPressHandler.onBackPressed()
}
}
scope.launch {
backPressHandler.canHandleBackPress.collect {
callback.isEnabled = it
}
}
callback
}

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ interface RoutingSource<Routing, State> : RoutingSourceAdapter<Routing, State>,

val elements: StateFlow<RoutingElements<Routing, out State>>

val canHandleBackPress: StateFlow<Boolean>

fun onTransitionFinished(key: RoutingKey<Routing>) {
onTransitionFinished(listOf(key))
}
Expand All @@ -25,5 +23,6 @@ interface RoutingSource<Routing, State> : RoutingSourceAdapter<Routing, State>,
fun accept(operation: Operation<Routing, State>) = Unit

override fun handleUpNavigation(): Boolean =
canHandleBackPress.value.also { if (it) onBackPressed() }
handleOnBackPressed()

}
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package com.bumble.appyx.core.routing.backpresshandlerstrategies

import com.bumble.appyx.core.plugin.BackPressHandler
import com.bumble.appyx.core.routing.BaseRoutingSource
import com.bumble.appyx.core.routing.RoutingSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow

interface BackPressHandlerStrategy<Routing, State>
: BackPressHandler {
interface BackPressHandlerStrategy<Routing, State> {

fun init(routingSource: BaseRoutingSource<Routing, State>, scope: CoroutineScope)

val canHandleBackPress: StateFlow<Boolean>

fun onBackPressed()

}
Loading