Fix bunch of issues around login/logout and introduces messages feature

This commit is contained in:
ganfra
2022-11-04 14:50:30 +01:00
parent bb88919bb3
commit 823cef8b7c
19 changed files with 314 additions and 89 deletions

View File

@@ -68,6 +68,7 @@ dependencies {
implementation project(":libraries:matrix")
implementation project(":features:login")
implementation project(":features:roomlist")
implementation project(":features:messages")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.2.0"
implementation 'io.github.raamcosta.compose-destinations:core:1.7.23-beta'

View File

@@ -33,7 +33,12 @@ private fun MainScreen(viewModel: MainViewModel) {
val engine = rememberNavHostEngine()
val navController = engine.rememberNavController()
val startRoute = runBlocking {
if (!viewModel.hasSession()) LoginScreenNavigationDestination else NavGraphs.root.startRoute
if (!viewModel.isLoggedIn()) {
LoginScreenNavigationDestination
} else {
viewModel.restoreSession()
NavGraphs.root.startRoute
}
}
DestinationsNavHost(

View File

@@ -2,11 +2,16 @@ package io.element.android.x
import androidx.lifecycle.ViewModel
import io.element.android.x.matrix.MatrixInstance
import kotlinx.coroutines.flow.first
class MainViewModel : ViewModel() {
private val matrix = MatrixInstance.getInstance()
suspend fun hasSession(): Boolean {
return matrix.restoreSession() != null
suspend fun isLoggedIn(): Boolean {
return matrix.isLoggedIn().first()
}
suspend fun restoreSession() {
matrix.restoreSession()
}
}

View File

@@ -4,17 +4,25 @@ import androidx.compose.runtime.Composable
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.destinations.LoginScreenNavigationDestination
import io.element.android.x.destinations.MessagesScreenNavigationDestination
import io.element.android.x.destinations.RoomListScreenNavigationDestination
import io.element.android.x.features.login.LoginScreen
import io.element.android.x.features.messages.MessagesScreen
import io.element.android.x.features.roomlist.RoomListScreen
import io.element.android.x.matrix.core.RoomId
@Destination
@Composable
fun LoginScreenNavigation(navigator: DestinationsNavigator) {
LoginScreen(
onLoginWithSuccess = {
navigator.clearBackStack(RoomListScreenNavigationDestination)
navigator.navigate(RoomListScreenNavigationDestination){
popUpTo(LoginScreenNavigationDestination){
inclusive = true
}
}
}
)
}
@@ -23,9 +31,24 @@ fun LoginScreenNavigation(navigator: DestinationsNavigator) {
@Destination
@Composable
fun RoomListScreenNavigation(navigator: DestinationsNavigator) {
RoomListScreen(onSuccessLogout = {
navigator.clearBackStack(LoginScreenNavigationDestination)
})
RoomListScreen(
onRoomClicked = { roomId: RoomId ->
navigator.navigate(MessagesScreenNavigationDestination(roomId = roomId.value))
},
onSuccessLogout = {
navigator.navigate(LoginScreenNavigationDestination){
popUpTo(RoomListScreenNavigationDestination){
inclusive = true
}
}
})
}
@Destination
@Composable
fun MessagesScreenNavigation(roomId: String) {
MessagesScreen(roomId)
}

View File

@@ -1,10 +1,6 @@
package io.element.android.x.features.login
import android.util.Log
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.Success
import io.element.android.x.matrix.MatrixInstance
import kotlinx.coroutines.launch
@@ -24,13 +20,11 @@ class LoginViewModel(initialState: LoginViewState) :
private fun handleSubmit() = withState { state ->
viewModelScope.launch {
setState { copy(isLoggedIn = Loading()) }
try {
suspend {
matrix.login(state.homeserver, state.login, state.password)
setState { copy(isLoggedIn = Success(Unit)) }
} catch (throwable: Throwable) {
Log.e("Error", "Cannot login", throwable)
setState { copy(isLoggedIn = Fail(throwable)) }
Unit
}.execute {
copy(isLoggedIn = it)
}
}
}

View File

@@ -1,41 +0,0 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'io.element.android.x.features.messages'
compileSdk 32
defaultConfig {
minSdk 24
targetSdk 32
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

View File

@@ -0,0 +1,19 @@
plugins {
id("io.element.android-compose")
}
android {
namespace = "io.element.android.x.features.messages"
}
dependencies {
implementation(project(":libraries:core"))
implementation(project(":libraries:matrix"))
implementation(project(":libraries:designsystem"))
implementation(libs.mavericks.compose)
implementation(libs.timber)
implementation(libs.datetime)
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
}

View File

@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

View File

@@ -0,0 +1,44 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.x.features.messages
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.data.LogCompositions
import io.element.android.x.features.messages.model.MessagesViewState
@Composable
fun MessagesScreen(roomId: String) {
val viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId })
LogCompositions(tag = "MessagesScreen", msg = "Root")
val roomTitle by viewModel.collectAsState(prop1 = MessagesViewState::roomTitle)
MessagesContent(roomTitle)
}
@Composable
fun MessagesContent(roomTitle: String) {
val appBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState)
LogCompositions(tag = "RoomListScreen", msg = "Content")
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text(text = roomTitle) }
)
},
content = { padding ->
Box(modifier = Modifier.padding(padding))
}
)
}

View File

@@ -0,0 +1,62 @@
package io.element.android.x.features.messages
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.Success
import io.element.android.x.core.data.parallelMap
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.model.MessagesViewState
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.MatrixInstance
import io.element.android.x.matrix.room.RoomSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
class MessagesViewModel(initialState: MessagesViewState) :
MavericksViewModel<MessagesViewState>(initialState) {
private val matrix = MatrixInstance.getInstance()
init {
handleInit()
}
private fun handleInit() {
viewModelScope.launch {
}
}
private suspend fun loadAvatarData(
client: MatrixClient,
name: String,
url: String?,
size: AvatarSize = AvatarSize.MEDIUM
): AvatarData {
val mediaContent = url?.let {
val mediaSource = mediaSourceFromUrl(it)
client.loadMediaThumbnailForSource(mediaSource, size.value.toLong(), size.value.toLong())
}
return mediaContent?.fold(
{ it },
{ null }
).let { model ->
AvatarData(name.first().uppercase(), model, size)
}
}
private suspend fun getClient(): MatrixClient {
return matrix.matrixClient().first().get()
}
override fun onCleared() {
super.onCleared()
}
}

View File

@@ -0,0 +1,16 @@
package io.element.android.x.features.messages.model
import com.airbnb.mvrx.MavericksState
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.matrix.core.RoomId
data class MessagesViewState(
val roomId: String,
val roomTitle: String = "",
val roomAvatar: AvatarData? = null
) : MavericksState {
@Suppress("unused")
constructor(roomId: String) : this(roomId = roomId, roomTitle = "", roomAvatar = null)
}

View File

@@ -14,11 +14,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.tooling.preview.Preview
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import io.element.android.x.core.data.LogCompositions
import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.designsystem.components.ProgressDialog
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.roomlist.components.RoomItem
import io.element.android.x.features.roomlist.components.RoomListTopBar
@@ -46,7 +48,8 @@ fun RoomListScreen(
roomSummaries = roomSummaries().orEmpty(),
matrixUser = matrixUser(),
onRoomClicked = onRoomClicked,
onLogoutClicked = viewModel::logout
onLogoutClicked = viewModel::logout,
isLoginOut = logoutAction is Loading
)
}
@@ -56,6 +59,7 @@ fun RoomListContent(
matrixUser: MatrixUser?,
onRoomClicked: (RoomId) -> Unit,
onLogoutClicked: () -> Unit,
isLoginOut: Boolean,
) {
val appBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState)
@@ -75,6 +79,9 @@ fun RoomListContent(
}
}
)
if (isLoginOut) {
ProgressDialog("Login out...")
}
}
@@ -86,7 +93,8 @@ private fun PreviewableRoomListContent() {
roomSummaries = stubbedRoomSummaries(),
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
onRoomClicked = {},
onLogoutClicked = {}
onLogoutClicked = {},
isLoginOut = false
)
}
}
@@ -99,7 +107,8 @@ private fun PreviewableDarkRoomListContent() {
roomSummaries = stubbedRoomSummaries(),
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
onRoomClicked = {},
onLogoutClicked = {}
onLogoutClicked = {},
isLoginOut = true
)
}
}

View File

@@ -1,9 +1,6 @@
package io.element.android.x.features.roomlist
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.Success
import io.element.android.x.core.data.parallelMap
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize
@@ -14,6 +11,8 @@ import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.MatrixInstance
import io.element.android.x.matrix.room.RoomSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -47,7 +46,12 @@ class RoomListViewModel(initialState: RoomListViewState) :
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
val userDisplayName = client.loadUserDisplayName().getOrNull()
val avatarData =
loadAvatarData(client, userDisplayName ?: client.userId().value, userAvatarUrl, AvatarSize.SMALL)
loadAvatarData(
client,
userDisplayName ?: client.userId().value,
userAvatarUrl,
AvatarSize.SMALL
)
MatrixUser(
username = userDisplayName ?: client.userId().value,
avatarUrl = userAvatarUrl,
@@ -101,7 +105,11 @@ class RoomListViewModel(initialState: RoomListViewState) :
): AvatarData {
val mediaContent = url?.let {
val mediaSource = mediaSourceFromUrl(it)
client.loadMediaThumbnailForSource(mediaSource, size.value.toLong(), size.value.toLong())
client.loadMediaThumbnailForSource(
mediaSource,
size.value.toLong(),
size.value.toLong()
)
}
return mediaContent?.fold(
{ it },
@@ -113,18 +121,17 @@ class RoomListViewModel(initialState: RoomListViewState) :
private fun handleLogout() {
viewModelScope.launch {
setState { copy(logoutAction = Loading()) }
try {
suspend {
delay(2000)
getClient().logout()
setState { copy(logoutAction = Success(Unit)) }
} catch (throwable: Throwable) {
setState { copy(logoutAction = Fail(throwable)) }
}.execute {
copy(logoutAction = it)
}
}
}
private suspend fun getClient(): MatrixClient {
return matrix.restoreSession()!!
return matrix.matrixClient().first().get()
}
override fun onCleared() {

View File

@@ -1,16 +1,12 @@
package io.element.android.x.designsystem
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.google.accompanist.systemuicontroller.rememberSystemUiController
private val DarkColorScheme = darkColorScheme(

View File

@@ -0,0 +1,39 @@
package io.element.android.x.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun ProgressDialog(text: String? = null, onDismiss: () -> Unit = {}) {
Dialog(
onDismissRequest = onDismiss,
DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.onBackground,
shape = RoundedCornerShape(8.dp)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background)
if (!text.isNullOrBlank()) {
Text(text = text, Modifier.padding(16.dp))
}
}
}
}
}

View File

@@ -4,15 +4,20 @@ import android.content.Context
import io.element.android.x.core.data.CoroutineDispatchers
import io.element.android.x.matrix.session.SessionStore
import io.element.android.x.matrix.util.logError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.AuthenticationService
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import java.io.File
import java.util.Optional
class Matrix(
coroutineScope: CoroutineScope,
context: Context,
) {
private val coroutineDispatchers = CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
@@ -20,9 +25,31 @@ class Matrix(
)
private val baseFolder = File(context.filesDir, "matrix")
private val sessionStore = SessionStore(context)
private val matrixClient = MutableStateFlow<Optional<MatrixClient>>(Optional.empty())
private val isLoggedIn = MutableStateFlow(false)
suspend fun restoreSession(): MatrixClient? {
return sessionStore.getStoredData()
init {
sessionStore.isLoggedIn()
.distinctUntilChanged()
.onEach { isLoggedIn ->
this.isLoggedIn.value = isLoggedIn
if (!isLoggedIn) {
matrixClient.value = Optional.empty()
}
}
.launchIn(coroutineScope)
}
fun isLoggedIn(): Flow<Boolean> {
return isLoggedIn
}
fun matrixClient(): Flow<Optional<MatrixClient>> {
return matrixClient
}
suspend fun restoreSession() = withContext(coroutineDispatchers.io) {
sessionStore.getStoredData()
?.let { sessionData ->
try {
ClientBuilder()
@@ -36,15 +63,26 @@ class Matrix(
null
}
}?.let {
MatrixClient(it, sessionStore, coroutineDispatchers)
createMatrixClient(it)
}
}
suspend fun login(homeserver: String, username: String, password: String): MatrixClient {
val authService = AuthenticationService(baseFolder.absolutePath)
authService.configureHomeserver(homeserver)
val client = authService.login(username, password, "MatrixRustSDKSample", null)
sessionStore.storeData(SessionStore.SessionData(client.userId(), client.restoreToken()))
return MatrixClient(client, sessionStore, coroutineDispatchers)
suspend fun login(homeserver: String, username: String, password: String): MatrixClient =
withContext(coroutineDispatchers.io) {
val authService = AuthenticationService(baseFolder.absolutePath)
authService.configureHomeserver(homeserver)
val client = authService.login(username, password, "MatrixRustSDKSample", null)
sessionStore.storeData(SessionStore.SessionData(client.userId(), client.restoreToken()))
createMatrixClient(client)
}
private fun createMatrixClient(client: Client): MatrixClient {
return MatrixClient(
client = client,
sessionStore = sessionStore,
dispatchers = coroutineDispatchers
).also {
matrixClient.value = Optional.of(it)
}
}
}

View File

@@ -61,7 +61,6 @@ class MatrixClient internal constructor(
init {
client.setDelegate(clientDelegate)
}
fun startSync() {
slidingSync.setObserver(slidingSyncObserver)
slidingSyncObserverToken = slidingSync.sync()

View File

@@ -2,6 +2,7 @@ package io.element.android.x.matrix
import android.annotation.SuppressLint
import android.content.Context
import kotlinx.coroutines.GlobalScope
object MatrixInstance {
@@ -9,7 +10,7 @@ object MatrixInstance {
private lateinit var instance: Matrix
fun init(context: Context) {
instance = Matrix(context)
instance = Matrix(GlobalScope, context)
}
fun getInstance(): Matrix {

View File

@@ -6,10 +6,13 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_sessions")
private val userIdPreference = stringPreferencesKey("userId")
// TODO It contains the access token, so it has to be stored in a more secured storage.
// I would expect the Rust SDK to provide a more obscure token.
private val restoreTokenPreference = stringPreferencesKey("restoreToken")
@@ -25,6 +28,11 @@ internal class SessionStore(
private val store = context.dataStore
fun isLoggedIn(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[userIdPreference] != null && prefs[restoreTokenPreference] != null
}
}
suspend fun storeData(sessionData: SessionData) {
store.edit { prefs ->