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

View file

@ -6,177 +6,48 @@
package io.element.android.x package io.element.android.x
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi 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.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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Surface
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.ui.Modifier 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.core.view.WindowCompat
import androidx.navigation.NavHostController import com.bumble.appyx.core.integration.NodeHost
import com.airbnb.android.showkase.models.Showkase import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.airbnb.mvrx.compose.mavericksActivityViewModel
import com.airbnb.mvrx.compose.mavericksViewModel
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi 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.DaggerComponentOwner
import io.element.android.x.core.di.bindings
import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.destinations.OnBoardingScreenNavigationDestination import io.element.android.x.di.AppBindings
import kotlinx.coroutines.runBlocking import io.element.android.x.node.RootFlowNode
import timber.log.Timber
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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val appBindings = bindings<AppBindings>()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
ElementXTheme { ElementXTheme {
MainScreen(viewModel = mavericksActivityViewModel()) // A surface container using the 'background' color from the theme
} Surface(
} modifier = Modifier.fillMaxSize(),
} color = MaterialTheme.colorScheme.background
@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,
) { ) {
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 com.squareup.anvil.annotations.ContributesTo
import io.element.android.x.matrix.Matrix 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 import kotlinx.coroutines.CoroutineScope
@ContributesTo(AppScope::class) @ContributesTo(AppScope::class)
@ -9,4 +11,4 @@ interface AppBindings {
fun coroutineScope(): CoroutineScope fun coroutineScope(): CoroutineScope
fun matrix(): Matrix fun matrix(): Matrix
fun sessionComponentsOwner(): SessionComponentsOwner fun sessionComponentsOwner(): SessionComponentsOwner
} }

View file

@ -3,17 +3,18 @@ package io.element.android.x.di
import android.content.Context import android.content.Context
import io.element.android.x.core.di.bindings import io.element.android.x.core.di.bindings
import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.core.SessionId
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class SessionComponentsOwner @Inject constructor(@ApplicationContext private val context: Context) { 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 var activeSessionComponent: SessionComponent? = null
private set private set
fun setActive(sessionId: String) { fun setActive(sessionId: SessionId) {
val sessionComponent = sessionComponents[sessionId] val sessionComponent = sessionComponents[sessionId]
if (activeSessionComponent != sessionComponent) { if (activeSessionComponent != sessionComponent) {
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) val sessionComponent = sessionComponents.remove(sessionId)
if (activeSessionComponent == sessionComponent) { if (activeSessionComponent == sessionComponent) {
activeSessionComponent = null 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()) Timber.plant(Timber.DebugTree())
} }
override fun dependencies(): List<Class<out Initializer<*>>> = override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
listOf(TimberInitializer::class.java) }
}

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

View file

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

View file

@ -48,22 +48,20 @@ class LoginViewModel @AssistedInject constructor(
val state = awaitState() val state = awaitState()
// Ensure the server is provided to the Rust SDK // Ensure the server is provided to the Rust SDK
matrix.setHomeserver(state.homeserver) matrix.setHomeserver(state.homeserver)
matrix.login(state.formState.login.trim(), state.formState.password.trim()).also { matrix.login(state.formState.login.trim(), state.formState.password.trim())
it.startSync()
}
}.execute { }.execute {
copy(loggedInClient = it) copy(loggedInSessionId = it)
} }
} }
} }
fun onSetPassword(password: String) { fun onSetPassword(password: String) {
formState.value = formState.value.copy(password = password) formState.value = formState.value.copy(password = password)
setState { copy(loggedInClient = Uninitialized) } setState { copy(loggedInSessionId = Uninitialized) }
} }
fun onSetName(name: String) { fun onSetName(name: String) {
formState.value = formState.value.copy(login = name) 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.Loading
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.core.SessionId
data class LoginViewState( data class LoginViewState(
val homeserver: String = "", val homeserver: String = "",
val loggedInClient: Async<MatrixClient> = Uninitialized, val loggedInSessionId: Async<SessionId> = Uninitialized,
val formState: LoginFormState = LoginFormState.Default, val formState: LoginFormState = LoginFormState.Default,
) : MavericksState { ) : MavericksState {
val submitEnabled = val submitEnabled =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInClient !is Loading formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInSessionId !is Loading
} }
data class LoginFormState( 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:matrix"))
implementation(project(":libraries:designsystem")) implementation(project(":libraries:designsystem"))
implementation(project(":libraries:textcomposer")) implementation(project(":libraries:textcomposer"))
implementation(libs.appyx.core)
implementation(libs.mavericks.compose) implementation(libs.mavericks.compose)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.datetime) implementation(libs.datetime)

View file

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

View file

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

View file

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

View file

@ -49,6 +49,7 @@ serialization_json = "1.4.1"
showkase = "1.0.0-beta14" showkase = "1.0.0-beta14"
compose_destinations = "1.7.23-beta" compose_destinations = "1.7.23-beta"
jsoup = "1.15.3" jsoup = "1.15.3"
appyx = "1.0.1"
# DI # DI
dagger = "2.43" 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 = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = {module = "com.bumble.appyx:core", version.ref = "appyx"}
# Di # Di
inject = {module = "javax.inject:javax.inject", version = "1"} inject = {module = "javax.inject:javax.inject", version = "1"}

View file

@ -10,4 +10,5 @@ dependencies {
api(libs.mavericks.compose) api(libs.mavericks.compose)
api(libs.dagger) api(libs.dagger)
api(libs.androidx.fragment) 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.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import androidx.fragment.app.Fragment 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: * 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)`. * 2) Contribute your interface to the correct component via `@ContributesTo(AppScope::class)`.
* 3) Call bindings<YourModuleBindings>().inject(this). * 3) Call bindings<YourModuleBindings>().inject(this).
*/ */
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java) 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> 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] */ /** Use no-arg extension function instead: [Context.bindings] */
fun <T : Any> Context.bindings(klass: Class<T>): T { fun <T : Any> Context.bindings(klass: Class<T>): T {
// search dagger components in the context hierarchy // search dagger components in the context hierarchy
@ -50,4 +54,16 @@ fun <T : Any> Fragment.bindings(klass: Class<T>): T {
.filterIsInstance(klass) .filterIsInstance(klass)
.firstOrNull() .firstOrNull()
?: requireActivity().bindings(klass) ?: 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.AppScope
import io.element.android.x.di.ApplicationContext import io.element.android.x.di.ApplicationContext
import io.element.android.x.di.SingleIn 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.MediaFetcher
import io.element.android.x.matrix.media.MediaKeyer import io.element.android.x.matrix.media.MediaKeyer
import io.element.android.x.matrix.session.SessionStore 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 io.element.android.x.matrix.util.logError
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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) { withContext(coroutineDispatchers.io) {
val client = try { val client = try {
authService.login(username, password, "ElementX Android", null) authService.login(username, password, "ElementX Android", null)
@ -88,8 +90,9 @@ class Matrix @Inject constructor(
Timber.e(failure, "Fail login") Timber.e(failure, "Fail login")
throw failure throw failure
} }
sessionStore.storeData(client.session()) val session = client.session()
createMatrixClient(client) sessionStore.storeData(session)
session.sessionId()
} }
private fun createMatrixClient(client: Client): MatrixClient { private fun createMatrixClient(client: Client): MatrixClient {

View file

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