Nav: First iteration integrating Appyx

This commit is contained in:
ganfra
2022-12-21 17:56:01 +01:00
parent c040e18431
commit 8b8b490bb2
28 changed files with 566 additions and 280 deletions

View File

@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kapt)
id("com.google.firebase.appdistribution") version "3.0.2"
id("org.jetbrains.kotlinx.knit") version "0.4.0"
id("kotlin-parcelize")
}
android {
@@ -147,6 +148,7 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.2.0")
implementation(libs.compose.destinations)
ksp(libs.compose.destinations.processor)
implementation(libs.appyx.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)

View File

@@ -6,177 +6,48 @@
package io.element.android.x
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController
import com.airbnb.android.showkase.models.Showkase
import com.airbnb.mvrx.compose.mavericksActivityViewModel
import com.airbnb.mvrx.compose.mavericksViewModel
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine
import com.ramcosta.composedestinations.manualcomposablecalls.animatedComposable
import com.ramcosta.composedestinations.navigation.dependency
import com.ramcosta.composedestinations.spec.Route
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.core.di.DaggerComponentOwner
import io.element.android.x.core.di.bindings
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import io.element.android.x.di.AppBindings
import io.element.android.x.node.RootFlowNode
private const val transitionAnimationDuration = 500
class MainActivity : NodeComponentActivity(), DaggerComponentOwner {
class MainActivity : ComponentActivity() {
override val daggerComponent: Any
get() = listOfNotNull((applicationContext as? DaggerComponentOwner)?.daggerComponent)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appBindings = bindings<AppBindings>()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ElementXTheme {
MainScreen(viewModel = mavericksActivityViewModel())
}
}
}
@Composable
private fun ShowkaseButton(
isVisible: Boolean,
onClick: () -> Unit,
onCloseClicked: () -> Unit
) {
if (isVisible) {
Button(
modifier = Modifier
.padding(top = 32.dp, start = 16.dp),
onClick = onClick
) {
Text(text = "Showkase Browser")
IconButton(
modifier = Modifier
.padding(start = 8.dp)
.size(16.dp),
onClick = onCloseClicked,
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "")
NodeHost(integrationPoint = appyxIntegrationPoint) {
RootFlowNode(
buildContext = it,
daggerComponentOwner = this,
matrix = appBindings.matrix(),
sessionComponentsOwner = appBindings.sessionComponentsOwner()
)
}
}
}
}
}
@Composable
private fun MainScreen(viewModel: MainViewModel) {
val startRoute = runBlocking {
if (!viewModel.isLoggedIn()) {
OnBoardingScreenNavigationDestination
} else {
viewModel.restoreSession()
NavGraphs.root.startRoute
}
}
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
MainContent(
startRoute = startRoute
)
ShowkaseButton(
isVisible = isShowkaseButtonVisible,
onCloseClicked = { isShowkaseButtonVisible = false },
onClick = { startActivity(Showkase.getBrowserIntent(this@MainActivity)) }
)
}
OnLifecycleEvent { _, event ->
Timber.v("OnLifecycleEvent: $event")
}
}
@Composable
private fun MainContent(startRoute: Route) {
val engine = rememberAnimatedNavHostEngine(
rootDefaultAnimations = RootNavGraphDefaultAnimations(
enterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = tween(transitionAnimationDuration)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = tween(transitionAnimationDuration)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = tween(transitionAnimationDuration)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = tween(transitionAnimationDuration)
)
}
)
)
val navController = engine.rememberNavController()
LogNavigation(navController)
DestinationsNavHost(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
engine = engine,
navController = navController,
navGraph = NavGraphs.root,
startRoute = startRoute,
dependenciesContainerBuilder = {
}
)
}
@Composable
private fun LogNavigation(navController: NavHostController) {
LaunchedEffect(key1 = navController) {
navController.appCurrentDestinationFlow.collect {
Timber.d("Navigating to ${it.route}")
}
}
}
@Composable
@Preview
fun MainContentPreview() {
MainContent(startRoute = OnBoardingScreenNavigationDestination)
}
}

View File

@@ -1,90 +0,0 @@
package io.element.android.x
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.popUpTo
import io.element.android.x.core.di.bindings
import io.element.android.x.destinations.ChangeServerScreenNavigationDestination
import io.element.android.x.destinations.LoginScreenNavigationDestination
import io.element.android.x.destinations.MessagesScreenNavigationDestination
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination
import io.element.android.x.destinations.RoomListScreenNavigationDestination
import io.element.android.x.di.AppBindings
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.login.changeserver.ChangeServerScreen
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.onboarding.OnBoardingScreen
import io.element.android.x.features.roomlist.RoomListScreen
import io.element.android.x.matrix.core.RoomId
@Destination
@Composable
fun OnBoardingScreenNavigation(navigator: DestinationsNavigator) {
OnBoardingScreen(
onSignUp = {
// TODO
},
onSignIn = {
navigator.navigate(LoginScreenNavigationDestination)
}
)
}
@Destination
@Composable
fun LoginScreenNavigation(navigator: DestinationsNavigator) {
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
LoginScreen(
onChangeServer = {
navigator.navigate(ChangeServerScreenNavigationDestination)
},
onLoginWithSuccess = {
sessionComponentsOwner.create(it)
navigator.navigate(RoomListScreenNavigationDestination) {
popUpTo(OnBoardingScreenNavigationDestination) {
inclusive = true
}
}
}
)
}
// TODO Create a subgraph in Login module
@Destination
@Composable
fun ChangeServerScreenNavigation(navigator: DestinationsNavigator) {
ChangeServerScreen(
onChangeServerSuccess = {
navigator.popBackStack()
}
)
}
@RootNavGraph(start = true)
@Destination
@Composable
fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
val sessionComponentsOwner = LocalContext.current.bindings<AppBindings>().sessionComponentsOwner()
RoomListScreen(
onRoomClicked = { roomId: RoomId ->
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
},
onSuccessLogout = {
sessionComponentsOwner.releaseActiveSession()
navigator.navigate(OnBoardingScreenNavigationDestination) {
popUpTo(RoomListScreenNavigationDestination) {
inclusive = true
}
}
}
)
}
@Destination
@Composable
fun MessagesScreenNavigation(roomId: String, navigator: DestinationsNavigator) {
MessagesScreen(roomId = roomId, onBackPressed = navigator::navigateUp)
}

View File

@@ -0,0 +1,39 @@
package io.element.android.x.component
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun ShowkaseButton(
modifier: Modifier = Modifier,
isVisible: Boolean,
onClick: () -> Unit,
onCloseClicked: () -> Unit
) {
if (isVisible) {
Button(
modifier = Modifier
.padding(top = 32.dp, start = 16.dp),
onClick = onClick
) {
Text(text = "Showkase Browser")
IconButton(
modifier = Modifier
.padding(start = 8.dp)
.size(16.dp),
onClick = onCloseClicked,
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "")
}
}
}
}

View File

@@ -2,6 +2,8 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.x.matrix.Matrix
import io.element.android.x.node.LoggedInFlowNode
import io.element.android.x.node.RootFlowNode
import kotlinx.coroutines.CoroutineScope
@ContributesTo(AppScope::class)
@@ -9,4 +11,4 @@ interface AppBindings {
fun coroutineScope(): CoroutineScope
fun matrix(): Matrix
fun sessionComponentsOwner(): SessionComponentsOwner
}
}

View File

@@ -3,17 +3,18 @@ package io.element.android.x.di
import android.content.Context
import io.element.android.x.core.di.bindings
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.SessionId
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@SingleIn(AppScope::class)
class SessionComponentsOwner @Inject constructor(@ApplicationContext private val context: Context) {
private val sessionComponents = ConcurrentHashMap<String, SessionComponent>()
private val sessionComponents = ConcurrentHashMap<SessionId, SessionComponent>()
var activeSessionComponent: SessionComponent? = null
private set
fun setActive(sessionId: String) {
fun setActive(sessionId: SessionId) {
val sessionComponent = sessionComponents[sessionId]
if (activeSessionComponent != sessionComponent) {
activeSessionComponent = sessionComponent
@@ -35,7 +36,7 @@ class SessionComponentsOwner @Inject constructor(@ApplicationContext private val
}
}
fun release(sessionId: String) {
fun release(sessionId: SessionId) {
val sessionComponent = sessionComponents.remove(sessionId)
if (activeSessionComponent == sessionComponent) {
activeSessionComponent = null

View File

@@ -17,7 +17,7 @@ class MatrixInitializer : Initializer<Unit> {
}
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(TimberInitializer::class.java)
}
}

View File

@@ -10,6 +10,5 @@ class TimberInitializer : Initializer<Unit> {
Timber.plant(Timber.DebugTree())
}
override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(TimberInitializer::class.java)
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

View File

@@ -0,0 +1,60 @@
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.core.di.viewModelSupportNode
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.roomlist.RoomListScreen
import io.element.android.x.matrix.core.RoomId
import io.element.android.x.matrix.core.SessionId
import kotlinx.parcelize.Parcelize
class LoggedInFlowNode(
buildContext: BuildContext,
val sessionId: SessionId,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<LoggedInFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object RoomList : NavTarget
@Parcelize
data class Messages(val roomId: RoomId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.RoomList -> viewModelSupportNode(buildContext) {
RoomListScreen(
onRoomClicked = { backstack.push(NavTarget.Messages(it)) }
)
}
is NavTarget.Messages -> viewModelSupportNode(buildContext) {
MessagesScreen(
roomId = navTarget.roomId.value,
onBackPressed = { backstack.pop() }
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View File

@@ -0,0 +1,51 @@
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace
import io.element.android.x.core.di.viewModelSupportNode
import io.element.android.x.features.login.node.LoginFlowNode
import io.element.android.x.features.onboarding.OnBoardingScreen
import kotlinx.parcelize.Parcelize
class NotLoggedInFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.OnBoarding,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<NotLoggedInFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object OnBoarding : NavTarget
@Parcelize
object LoginFlow : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.OnBoarding -> viewModelSupportNode(buildContext) {
OnBoardingScreen(
onSignIn = { backstack.replace(NavTarget.LoginFlow) }
)
}
NavTarget.LoginFlow -> LoginFlowNode(buildContext)
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View File

@@ -0,0 +1,136 @@
package io.element.android.x.node
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.airbnb.android.showkase.models.Showkase
import com.bumble.appyx.core.children.whenChildAttached
import com.bumble.appyx.core.clienthelper.interactor.Interactor
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace
import io.element.android.x.BuildConfig
import io.element.android.x.component.ShowkaseButton
import io.element.android.x.core.di.DaggerComponentOwner
import io.element.android.x.di.SessionComponentsOwner
import io.element.android.x.getBrowserIntent
import io.element.android.x.matrix.Matrix
import io.element.android.x.matrix.core.SessionId
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import timber.log.Timber
class SessionComponentsOwnerInteractor(private val sessionComponentsOwner: SessionComponentsOwner) : Interactor<RootFlowNode>() {
override fun onCreate(lifecycle: Lifecycle) {
lifecycle.subscribe(onCreate = {
whenChildAttached { commonLifecycle: Lifecycle, child: LoggedInFlowNode ->
Timber.v("LoggedInFlowNode attached: ${child.sessionId} ")
commonLifecycle.subscribe(
onDestroy = {
Timber.v("LoggedInFlowNode destroyed: ${child.sessionId}")
sessionComponentsOwner.release(child.sessionId)
}
)
}
})
}
}
class RootFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap,
),
private val daggerComponentOwner: DaggerComponentOwner,
private val matrix: Matrix,
private val sessionComponentsOwner: SessionComponentsOwner,
) :
ParentNode<RootFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext,
plugins = listOf(SessionComponentsOwnerInteractor(sessionComponentsOwner)),
),
DaggerComponentOwner by daggerComponentOwner {
init {
matrix.isLoggedIn()
.distinctUntilChanged()
.onEach { isLoggedIn ->
if (isLoggedIn) {
val matrixClient = matrix.restoreSession()
if (matrixClient == null) {
backstack.replace(NavTarget.NotLoggedInFlow)
} else {
matrixClient.startSync()
sessionComponentsOwner.create(matrixClient)
backstack.replace(NavTarget.LoggedInFlow(matrixClient.sessionId))
}
} else {
backstack.replace(NavTarget.NotLoggedInFlow)
}
}
.launchIn(lifecycleScope)
}
@Composable
override fun View(modifier: Modifier) {
var isShowkaseButtonVisible by remember { mutableStateOf(BuildConfig.DEBUG) }
Box(
modifier = modifier
.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Children(navModel = backstack)
val context = LocalContext.current
ShowkaseButton(
isVisible = isShowkaseButtonVisible,
onCloseClicked = { isShowkaseButtonVisible = false },
onClick = { startActivity(context, Showkase.getBrowserIntent(context), null) }
)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
object SplashScreen : NavTarget
@Parcelize
object NotLoggedInFlow : NavTarget
@Parcelize
data class LoggedInFlow(val sessionId: SessionId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LoggedInFlow -> LoggedInFlowNode(buildContext, navTarget.sessionId)
NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext)
NavTarget.SplashScreen -> node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}

View File

@@ -2,6 +2,7 @@ plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
id("kotlin-parcelize")
}
android {
@@ -21,6 +22,7 @@ dependencies {
implementation(project(":libraries:matrix"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:elementresources"))
implementation(libs.appyx.core)
implementation(libs.mavericks.compose)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)

View File

@@ -49,14 +49,14 @@ import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.features.login.error.loginError
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.SessionId
import timber.log.Timber
@Composable
fun LoginScreen(
viewModel: LoginViewModel = mavericksViewModel(),
onChangeServer: () -> Unit = { },
onLoginWithSuccess: (MatrixClient) -> Unit = { },
onLoginWithSuccess: (SessionId) -> Unit = { },
) {
val state: LoginViewState by viewModel.collectAsState()
val formState: LoginFormState by viewModel.formState
@@ -85,7 +85,7 @@ fun LoginContent(
onLoginChanged: (String) -> Unit = {},
onPasswordChanged: (String) -> Unit = {},
onSubmitClicked: () -> Unit = {},
onLoginWithSuccess: (MatrixClient) -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {},
) {
Surface(
modifier = modifier,
@@ -105,7 +105,7 @@ fun LoginContent(
)
.padding(horizontal = 16.dp),
) {
val isError = state.loggedInClient is Fail
val isError = state.loggedInSessionId is Fail
// Title
Text(
text = "Welcome back",
@@ -160,7 +160,7 @@ fun LoginContent(
),
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInClient is Loading) {
if (state.loggedInSessionId is Loading) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
@@ -193,9 +193,9 @@ fun LoginContent(
onDone = { onSubmitClicked() }
),
)
if (state.loggedInClient is Fail) {
if (state.loggedInSessionId is Fail) {
Text(
text = loginError(state.formState, state.loggedInClient.error),
text = loginError(state.formState, state.loggedInSessionId.error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
@@ -212,12 +212,12 @@ fun LoginContent(
) {
Text(text = "Continue")
}
when (val loggedInClient = state.loggedInClient) {
is Success -> onLoginWithSuccess(loggedInClient())
when (val loggedInSessionId = state.loggedInSessionId) {
is Success -> onLoginWithSuccess(loggedInSessionId())
else -> Unit
}
}
if (state.loggedInClient is Loading) {
if (state.loggedInSessionId is Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)

View File

@@ -48,22 +48,20 @@ class LoginViewModel @AssistedInject constructor(
val state = awaitState()
// Ensure the server is provided to the Rust SDK
matrix.setHomeserver(state.homeserver)
matrix.login(state.formState.login.trim(), state.formState.password.trim()).also {
it.startSync()
}
matrix.login(state.formState.login.trim(), state.formState.password.trim())
}.execute {
copy(loggedInClient = it)
copy(loggedInSessionId = it)
}
}
}
fun onSetPassword(password: String) {
formState.value = formState.value.copy(password = password)
setState { copy(loggedInClient = Uninitialized) }
setState { copy(loggedInSessionId = Uninitialized) }
}
fun onSetName(name: String) {
formState.value = formState.value.copy(login = name)
setState { copy(loggedInClient = Uninitialized) }
setState { copy(loggedInSessionId = Uninitialized) }
}
}

View File

@@ -4,15 +4,15 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.SessionId
data class LoginViewState(
val homeserver: String = "",
val loggedInClient: Async<MatrixClient> = Uninitialized,
val loggedInSessionId: Async<SessionId> = Uninitialized,
val formState: LoginFormState = LoginFormState.Default,
) : MavericksState {
val submitEnabled =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInClient !is Loading
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInSessionId !is Loading
}
data class LoginFormState(

View File

@@ -0,0 +1,57 @@
package io.element.android.x.features.login.node
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.core.di.viewModelSupportNode
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.login.changeserver.ChangeServerScreen
import kotlinx.parcelize.Parcelize
class LoginFlowNode(
buildContext: BuildContext,
private val backstack: BackStack<NavTarget> = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
) : ParentNode<LoginFlowNode.NavTarget>(
navModel = backstack,
buildContext = buildContext
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
@Parcelize
object ChangeServer : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> viewModelSupportNode(buildContext) {
LoginScreen(
onChangeServer = { backstack.push(NavTarget.ChangeServer) }
)
}
NavTarget.ChangeServer -> viewModelSupportNode(buildContext) {
ChangeServerScreen(
onChangeServerSuccess = { backstack.pop() }
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(navModel = backstack)
}
}

View File

@@ -21,6 +21,7 @@ dependencies {
implementation(project(":libraries:matrix"))
implementation(project(":libraries:designsystem"))
implementation(project(":libraries:textcomposer"))
implementation(libs.appyx.core)
implementation(libs.mavericks.compose)
implementation(libs.coil.compose)
implementation(libs.datetime)

View File

@@ -14,6 +14,7 @@ dependencies {
implementation(libs.mavericks.compose)
implementation(libs.accompanist.pager)
implementation(libs.accompanist.pagerindicator)
implementation(libs.appyx.core)
testImplementation(libs.test.junit)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)

View File

@@ -11,6 +11,7 @@ class SplashCarouselStateFactory {
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
if (lightTheme) lightDrawable else darkDrawable
return SplashCarouselState(
listOf(
SplashCarouselState.Item(

View File

@@ -19,6 +19,7 @@ dependencies {
implementation(project(":libraries:core"))
implementation(project(":libraries:matrix"))
implementation(project(":libraries:designsystem"))
implementation(libs.appyx.core)
implementation(libs.mavericks.compose)
implementation(libs.datetime)
implementation(libs.accompanist.placeholder)

View File

@@ -49,6 +49,7 @@ serialization_json = "1.4.1"
showkase = "1.0.0-beta14"
compose_destinations = "1.7.23-beta"
jsoup = "1.15.3"
appyx = "1.0.1"
# DI
dagger = "2.43"
@@ -117,7 +118,7 @@ compose_destinations_processor = { module = "io.github.raamcosta.compose-destina
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = {module = "com.bumble.appyx:core", version.ref = "appyx"}
# Di
inject = {module = "javax.inject:javax.inject", version = "1"}

View File

@@ -10,4 +10,5 @@ dependencies {
api(libs.mavericks.compose)
api(libs.dagger)
api(libs.androidx.fragment)
api(libs.appyx.core)
}

View File

@@ -3,6 +3,7 @@ package io.element.android.x.core.di
import android.content.Context
import android.content.ContextWrapper
import androidx.fragment.app.Fragment
import com.bumble.appyx.core.node.Node
/**
* Use this to get the Dagger "Bindings" for your module. Bindings are used if you need to directly interact with a dagger component such as:
@@ -20,6 +21,7 @@ import androidx.fragment.app.Fragment
* 2) Contribute your interface to the correct component via `@ContributesTo(AppScope::class)`.
* 3) Call bindings<YourModuleBindings>().inject(this).
*/
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
/**
@@ -27,6 +29,8 @@ inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
*/
inline fun <reified T : Any> Fragment.bindings() = bindings(T::class.java)
inline fun <reified T : Any> Node.bindings() = bindings(T::class.java)
/** Use no-arg extension function instead: [Context.bindings] */
fun <T : Any> Context.bindings(klass: Class<T>): T {
// search dagger components in the context hierarchy
@@ -50,4 +54,16 @@ fun <T : Any> Fragment.bindings(klass: Class<T>): T {
.filterIsInstance(klass)
.firstOrNull()
?: requireActivity().bindings(klass)
}
}
/** Use no-arg extension function instead: [Node.bindings] */
fun <T : Any> Node.bindings(klass: Class<T>): T {
// search dagger components in node hierarchy
return generateSequence(this, Node::parent)
.filterIsInstance<DaggerComponentOwner>()
.map { it.daggerComponent }
.flatMap { if (it is Collection<*>) it else listOf(it) }
.filterIsInstance(klass)
.firstOrNull()
?: error("Unable to find bindings for ${klass.name}")
}

View File

@@ -0,0 +1,121 @@
package io.element.android.x.core.di
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.DEFAULT_ARGS_KEY
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.HasDefaultViewModelProviderFactory
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY
import androidx.lifecycle.SavedStateViewModelFactory
import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.enableSavedStateHandles
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.MutableCreationExtras
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
fun viewModelSupportNode(buildContext: BuildContext, plugins: List<Plugin> = emptyList(), composable: @Composable (Modifier) -> Unit): Node =
ViewModelSupportNode(buildContext, plugins, composable)
class ViewModelSupportNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList(),
private val composable: @Composable (Modifier) -> Unit,
) : Node(
buildContext, plugins = plugins
), ViewModelStoreOwner, SavedStateRegistryOwner {
private val viewModelSupport = ViewModelSupport(
lifecycle,
buildContext.savedStateMap?.get("SAVED_STATE_REGISTRY") as Bundle?,
)
override fun getViewModelStore(): ViewModelStore {
return viewModelSupport.viewModelStore
}
override val savedStateRegistry: SavedStateRegistry
get() = viewModelSupport.savedStateRegistry
@Composable
override fun View(modifier: Modifier) {
composable(modifier)
}
}
private class ViewModelSupport(
private val lifecycle: Lifecycle,
private val initialSavedState: Bundle?,
val defaultArgs: Bundle? = null,
) : ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner {
private val viewModelStore = ViewModelStore()
private val savedStateRegistryController: SavedStateRegistryController =
SavedStateRegistryController.create(this)
//Don't replace the initial saved state until we have at least started
private var canSaveState: Boolean = false
init {
savedStateRegistryController.performAttach()
// We copy the bundle because the `savedStateRegistryController` will modify it.
// We don't want to modify `initialSavedState` since we may need to return that as our
// state in `saveState`.
savedStateRegistryController.performRestore(initialSavedState?.let { Bundle(it) })
enableSavedStateHandles()
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
canSaveState = true
}
override fun onDestroy(owner: LifecycleOwner) {
viewModelStore.clear()
}
})
}
override fun getViewModelStore(): ViewModelStore {
return viewModelStore
}
override fun getDefaultViewModelProviderFactory(): ViewModelProvider.Factory {
return SavedStateViewModelFactory(null, this, defaultArgs)
}
override fun getDefaultViewModelCreationExtras(): CreationExtras {
val extras = MutableCreationExtras()
extras[SAVED_STATE_REGISTRY_OWNER_KEY] = this
extras[VIEW_MODEL_STORE_OWNER_KEY] = this
defaultArgs?.let { args ->
extras[DEFAULT_ARGS_KEY] = args
}
return extras
}
override val savedStateRegistry: SavedStateRegistry
get() = savedStateRegistryController.savedStateRegistry
override fun getLifecycle(): Lifecycle {
return lifecycle
}
fun saveState(): Bundle? {
return if (canSaveState) {
Bundle().also(savedStateRegistryController::performSave)
} else {
initialSavedState
}
}
}

View File

@@ -6,9 +6,11 @@ import io.element.android.x.core.coroutine.CoroutineDispatchers
import io.element.android.x.di.AppScope
import io.element.android.x.di.ApplicationContext
import io.element.android.x.di.SingleIn
import io.element.android.x.matrix.core.SessionId
import io.element.android.x.matrix.media.MediaFetcher
import io.element.android.x.matrix.media.MediaKeyer
import io.element.android.x.matrix.session.SessionStore
import io.element.android.x.matrix.session.sessionId
import io.element.android.x.matrix.util.logError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -80,7 +82,7 @@ class Matrix @Inject constructor(
}
}
suspend fun login(username: String, password: String): MatrixClient =
suspend fun login(username: String, password: String): SessionId =
withContext(coroutineDispatchers.io) {
val client = try {
authService.login(username, password, "ElementX Android", null)
@@ -88,8 +90,9 @@ class Matrix @Inject constructor(
Timber.e(failure, "Fail login")
throw failure
}
sessionStore.storeData(client.session())
createMatrixClient(client)
val session = client.session()
sessionStore.storeData(session)
session.sessionId()
}
private fun createMatrixClient(client: Client): MatrixClient {

View File

@@ -1,7 +1,7 @@
package io.element.android.x.matrix
import io.element.android.x.core.coroutine.CoroutineDispatchers
import io.element.android.x.di.SingleIn
import io.element.android.x.matrix.core.SessionId
import io.element.android.x.matrix.core.UserId
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.media.RustMediaResolver
@@ -9,10 +9,8 @@ import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.room.RoomSummaryDataSource
import io.element.android.x.matrix.room.RustRoomSummaryDataSource
import io.element.android.x.matrix.session.SessionStore
import io.element.android.x.matrix.session.sessionId
import io.element.android.x.matrix.sync.SlidingSyncObserverProxy
import java.io.Closeable
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
@@ -23,6 +21,9 @@ import org.matrix.rustcomponents.sdk.SlidingSyncMode
import org.matrix.rustcomponents.sdk.SlidingSyncViewBuilder
import org.matrix.rustcomponents.sdk.StoppableSpawn
import timber.log.Timber
import java.io.Closeable
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
class MatrixClient internal constructor(
private val client: Client,
@@ -32,8 +33,7 @@ class MatrixClient internal constructor(
private val baseDirectory: File,
) : Closeable {
val sessionId: String
get() = "${client.session().userId}_${client.session().deviceId}"
val sessionId: SessionId = client.session().sessionId()
private val clientDelegate = object : ClientDelegate {
override fun didReceiveAuthError(isSoftLogout: Boolean) {

View File

@@ -0,0 +1,6 @@
package io.element.android.x.matrix.core
import java.io.Serializable
@JvmInline
value class SessionId(val value: String) : Serializable

View File

@@ -0,0 +1,6 @@
package io.element.android.x.matrix.session
import io.element.android.x.matrix.core.SessionId
import org.matrix.rustcomponents.sdk.Session
fun Session.sessionId() = SessionId("${userId}_${deviceId}")