Merge branch 'develop' into feature/bma/leaveSpace
This commit is contained in:
commit
a6720078b4
117 changed files with 2158 additions and 287 deletions
|
|
@ -26,9 +26,11 @@ dependencies {
|
||||||
allFeaturesApi(project)
|
allFeaturesApi(project)
|
||||||
|
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.accountselect.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.deeplink.api)
|
implementation(projects.libraries.deeplink.api)
|
||||||
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.oidc.api)
|
implementation(projects.libraries.oidc.api)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class LoggedInAppScopeFlowNode(
|
||||||
), DependencyInjectionGraphOwner {
|
), DependencyInjectionGraphOwner {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onOpenBugReport()
|
fun onOpenBugReport()
|
||||||
|
fun onAddAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
|
@ -83,6 +84,10 @@ class LoggedInAppScopeFlowNode(
|
||||||
override fun onOpenBugReport() {
|
override fun onOpenBugReport() {
|
||||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAddAccount() {
|
||||||
|
plugins<Callback>().forEach { it.onAddAccount() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
|
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
|
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
|
|
@ -139,6 +138,7 @@ class LoggedInFlowNode(
|
||||||
) {
|
) {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onOpenBugReport()
|
fun onOpenBugReport()
|
||||||
|
fun onAddAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val loggedInFlowProcessor = LoggedInEventProcessor(
|
private val loggedInFlowProcessor = LoggedInEventProcessor(
|
||||||
|
|
@ -393,6 +393,10 @@ class LoggedInFlowNode(
|
||||||
}
|
}
|
||||||
is NavTarget.Settings -> {
|
is NavTarget.Settings -> {
|
||||||
val callback = object : PreferencesEntryPoint.Callback {
|
val callback = object : PreferencesEntryPoint.Callback {
|
||||||
|
override fun onAddAccount() {
|
||||||
|
plugins<Callback>().forEach { it.onAddAccount() }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpenBugReport() {
|
override fun onOpenBugReport() {
|
||||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||||
}
|
}
|
||||||
|
|
@ -405,11 +409,7 @@ class LoggedInFlowNode(
|
||||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
|
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
|
override fun navigateTo(roomId: RoomId, eventId: EventId) {
|
||||||
// We do not check the sessionId, but it will have to be done at some point (multi account)
|
|
||||||
if (sessionId != matrixClient.sessionId) {
|
|
||||||
Timber.e("SessionId mismatch, expected ${matrixClient.sessionId} but got $sessionId")
|
|
||||||
}
|
|
||||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId)))
|
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ package io.element.android.appnav
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -23,6 +25,8 @@ import com.bumble.appyx.core.state.MutableSavedStateMap
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
|
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
|
||||||
|
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
|
@ -39,13 +43,17 @@ import io.element.android.features.login.api.accesscontrol.AccountProviderAccess
|
||||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||||
|
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
|
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.architecture.waitForChildAttached
|
import io.element.android.libraries.architecture.waitForChildAttached
|
||||||
import io.element.android.libraries.core.uri.ensureProtocol
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
|
|
@ -56,12 +64,11 @@ import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class) @AssistedInject class RootFlowNode(
|
||||||
@AssistedInject
|
|
||||||
class RootFlowNode(
|
|
||||||
@Assisted val buildContext: BuildContext,
|
@Assisted val buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
|
|
@ -71,9 +78,11 @@ class RootFlowNode(
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||||
private val signedOutEntryPoint: SignedOutEntryPoint,
|
private val signedOutEntryPoint: SignedOutEntryPoint,
|
||||||
|
private val accountSelectEntryPoint: AccountSelectEntryPoint,
|
||||||
private val intentResolver: IntentResolver,
|
private val intentResolver: IntentResolver,
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
private val bugReporter: BugReporter,
|
private val bugReporter: BugReporter,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.SplashScreen,
|
initialElement = NavTarget.SplashScreen,
|
||||||
|
|
@ -95,27 +104,24 @@ class RootFlowNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeNavState() {
|
private fun observeNavState() {
|
||||||
navStateFlowFactory.create(buildContext.savedStateMap)
|
navStateFlowFactory.create(buildContext.savedStateMap).distinctUntilChanged().onEach { navState ->
|
||||||
.distinctUntilChanged()
|
Timber.v("navState=$navState")
|
||||||
.onEach { navState ->
|
when (navState.loggedInState) {
|
||||||
Timber.v("navState=$navState")
|
is LoggedInState.LoggedIn -> {
|
||||||
when (navState.loggedInState) {
|
if (navState.loggedInState.isTokenValid) {
|
||||||
is LoggedInState.LoggedIn -> {
|
tryToRestoreLatestSession(
|
||||||
if (navState.loggedInState.isTokenValid) {
|
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
|
||||||
tryToRestoreLatestSession(
|
onFailure = { switchToNotLoggedInFlow(null) }
|
||||||
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
|
)
|
||||||
onFailure = { switchToNotLoggedInFlow(null) }
|
} else {
|
||||||
)
|
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
|
||||||
} else {
|
|
||||||
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LoggedInState.NotLoggedIn -> {
|
|
||||||
switchToNotLoggedInFlow(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LoggedInState.NotLoggedIn -> {
|
||||||
|
switchToNotLoggedInFlow(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
}.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
|
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
|
||||||
|
|
@ -137,20 +143,17 @@ class RootFlowNode(
|
||||||
onFailure: () -> Unit,
|
onFailure: () -> Unit,
|
||||||
onSuccess: (SessionId) -> Unit,
|
onSuccess: (SessionId) -> Unit,
|
||||||
) {
|
) {
|
||||||
matrixSessionCache.getOrRestore(sessionId)
|
matrixSessionCache.getOrRestore(sessionId).onSuccess {
|
||||||
.onSuccess {
|
Timber.v("Succeed to restore session $sessionId")
|
||||||
Timber.v("Succeed to restore session $sessionId")
|
onSuccess(sessionId)
|
||||||
onSuccess(sessionId)
|
}.onFailure {
|
||||||
}
|
Timber.e(it, "Failed to restore session $sessionId")
|
||||||
.onFailure {
|
onFailure()
|
||||||
Timber.e(it, "Failed to restore session $sessionId")
|
}
|
||||||
onFailure()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun tryToRestoreLatestSession(
|
private suspend fun tryToRestoreLatestSession(
|
||||||
onSuccess: (SessionId) -> Unit,
|
onSuccess: (SessionId) -> Unit, onFailure: () -> Unit
|
||||||
onFailure: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val latestSessionId = sessionStore.getLatestSessionId()
|
val latestSessionId = sessionStore.getLatestSessionId()
|
||||||
if (latestSessionId == null) {
|
if (latestSessionId == null) {
|
||||||
|
|
@ -172,32 +175,45 @@ class RootFlowNode(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onOpenBugReport = this::onOpenBugReport,
|
onOpenBugReport = this::onOpenBugReport,
|
||||||
) {
|
) {
|
||||||
BackstackView()
|
val backstackSlider = rememberBackstackSlider<NavTarget>(
|
||||||
|
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||||
|
)
|
||||||
|
val backstackFader = rememberBackstackFader<NavTarget>(
|
||||||
|
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||||
|
)
|
||||||
|
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
|
||||||
|
when (navTarget) {
|
||||||
|
is NavTarget.SplashScreen,
|
||||||
|
is NavTarget.LoggedInFlow -> backstackFader
|
||||||
|
else -> backstackSlider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackstackView(transitionHandler = transitionHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize data object SplashScreen : NavTarget
|
||||||
data object SplashScreen : NavTarget
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize data class AccountSelect(
|
||||||
data class NotLoggedInFlow(
|
val currentSessionId: SessionId,
|
||||||
|
val intent: Intent?,
|
||||||
|
val permalinkData: PermalinkData?,
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize data class NotLoggedInFlow(
|
||||||
val params: LoginParams?
|
val params: LoginParams?
|
||||||
) : NavTarget
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize data class LoggedInFlow(
|
||||||
data class LoggedInFlow(
|
val sessionId: SessionId, val navId: Int
|
||||||
val sessionId: SessionId,
|
|
||||||
val navId: Int
|
|
||||||
) : NavTarget
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize data class SignedOutFlow(
|
||||||
data class SignedOutFlow(
|
|
||||||
val sessionId: SessionId
|
val sessionId: SessionId
|
||||||
) : NavTarget
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize data object BugReport : NavTarget
|
||||||
data object BugReport : NavTarget
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -211,6 +227,10 @@ class RootFlowNode(
|
||||||
override fun onOpenBugReport() {
|
override fun onOpenBugReport() {
|
||||||
backstack.push(NavTarget.BugReport)
|
backstack.push(NavTarget.BugReport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAddAccount() {
|
||||||
|
backstack.push(NavTarget.NotLoggedInFlow(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
|
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
|
||||||
}
|
}
|
||||||
|
|
@ -226,13 +246,11 @@ class RootFlowNode(
|
||||||
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
|
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
|
||||||
}
|
}
|
||||||
is NavTarget.SignedOutFlow -> {
|
is NavTarget.SignedOutFlow -> {
|
||||||
signedOutEntryPoint.nodeBuilder(this, buildContext)
|
signedOutEntryPoint.nodeBuilder(this, buildContext).params(
|
||||||
.params(
|
SignedOutEntryPoint.Params(
|
||||||
SignedOutEntryPoint.Params(
|
sessionId = navTarget.sessionId
|
||||||
sessionId = navTarget.sessionId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.build()
|
).build()
|
||||||
}
|
}
|
||||||
NavTarget.SplashScreen -> splashNode(buildContext)
|
NavTarget.SplashScreen -> splashNode(buildContext)
|
||||||
NavTarget.BugReport -> {
|
NavTarget.BugReport -> {
|
||||||
|
|
@ -241,10 +259,32 @@ class RootFlowNode(
|
||||||
backstack.pop()
|
backstack.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bugReportEntryPoint
|
bugReportEntryPoint.nodeBuilder(this, buildContext).callback(callback).build()
|
||||||
.nodeBuilder(this, buildContext)
|
}
|
||||||
.callback(callback)
|
is NavTarget.AccountSelect -> {
|
||||||
.build()
|
val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback {
|
||||||
|
override fun onSelectAccount(sessionId: SessionId) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
if (sessionId == navTarget.currentSessionId) {
|
||||||
|
// Ensure that the account selection Node is removed from the backstack
|
||||||
|
// Do not pop when the account is changed to avoid a UI flicker.
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
attachSession(sessionId).apply {
|
||||||
|
if (navTarget.intent != null) {
|
||||||
|
attachIncomingShare(navTarget.intent)
|
||||||
|
} else if (navTarget.permalinkData != null) {
|
||||||
|
attachPermalinkData(navTarget.permalinkData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accountSelectEntryPoint.nodeBuilder(this, buildContext).callback(callback).build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -267,19 +307,29 @@ class RootFlowNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onLoginLink(params: LoginParams) {
|
private suspend fun onLoginLink(params: LoginParams) {
|
||||||
// Is there a session already?
|
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
|
||||||
val latestSessionId = sessionStore.getLatestSessionId()
|
// Is there a session already?
|
||||||
if (latestSessionId == null) {
|
val sessions = sessionStore.getAllSessions()
|
||||||
// No session, open login
|
if (sessions.isNotEmpty()) {
|
||||||
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
|
if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
|
||||||
switchToNotLoggedInFlow(params)
|
val loginHintMatrixId = params.loginHint?.removePrefix("mxid:")
|
||||||
|
val existingAccount = sessions.find { it.userId == loginHintMatrixId }
|
||||||
|
if (existingAccount != null) {
|
||||||
|
// We have an existing account matching the login hint, ensure this is the current session
|
||||||
|
sessionStore.setLatestSession(existingAccount.userId)
|
||||||
|
} else {
|
||||||
|
val latestSessionId = sessions.maxBy { it.lastUsageIndex }.userId
|
||||||
|
attachSession(SessionId(latestSessionId))
|
||||||
|
backstack.push(NavTarget.NotLoggedInFlow(params))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.w("Login link ignored, multi account is disabled")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
|
switchToNotLoggedInFlow(params)
|
||||||
switchToNotLoggedInFlow(null)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Just ignore the login link if we already have a session
|
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
|
||||||
Timber.w("Login link ignored, we already have a session")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,56 +340,95 @@ class RootFlowNode(
|
||||||
// No session, open login
|
// No session, open login
|
||||||
switchToNotLoggedInFlow(null)
|
switchToNotLoggedInFlow(null)
|
||||||
} else {
|
} else {
|
||||||
attachSession(latestSessionId)
|
// wait for the current session to be restored
|
||||||
.attachIncomingShare(intent)
|
val loggedInFlowNode = attachSession(latestSessionId)
|
||||||
|
if (sessionStore.getAllSessions().size > 1) {
|
||||||
|
// Several accounts, let the user choose which one to use
|
||||||
|
backstack.push(
|
||||||
|
NavTarget.AccountSelect(
|
||||||
|
currentSessionId = latestSessionId,
|
||||||
|
intent = intent,
|
||||||
|
permalinkData = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Only one account, directly attach the incoming share node.
|
||||||
|
loggedInFlowNode.attachIncomingShare(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun navigateTo(permalinkData: PermalinkData) {
|
private suspend fun navigateTo(permalinkData: PermalinkData) {
|
||||||
Timber.d("Navigating to $permalinkData")
|
Timber.d("Navigating to $permalinkData")
|
||||||
attachSession(null)
|
// Is there a session already?
|
||||||
.apply {
|
val latestSessionId = sessionStore.getLatestSessionId()
|
||||||
when (permalinkData) {
|
if (latestSessionId == null) {
|
||||||
is PermalinkData.FallbackLink -> Unit
|
// No session, open login
|
||||||
is PermalinkData.RoomEmailInviteLink -> Unit
|
switchToNotLoggedInFlow(null)
|
||||||
is PermalinkData.RoomLink -> {
|
} else {
|
||||||
attachRoom(
|
// wait for the current session to be restored
|
||||||
roomIdOrAlias = permalinkData.roomIdOrAlias,
|
val loggedInFlowNode = attachSession(latestSessionId)
|
||||||
trigger = JoinedRoom.Trigger.MobilePermalink,
|
when (permalinkData) {
|
||||||
serverNames = permalinkData.viaParameters,
|
is PermalinkData.FallbackLink -> Unit
|
||||||
eventId = permalinkData.eventId,
|
is PermalinkData.RoomEmailInviteLink -> Unit
|
||||||
clearBackstack = true
|
else -> {
|
||||||
|
if (sessionStore.getAllSessions().size > 1) {
|
||||||
|
// Several accounts, let the user choose which one to use
|
||||||
|
backstack.push(
|
||||||
|
NavTarget.AccountSelect(
|
||||||
|
currentSessionId = latestSessionId,
|
||||||
|
intent = null,
|
||||||
|
permalinkData = permalinkData,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
is PermalinkData.UserLink -> {
|
// Only one account, directly attach the room or the user node.
|
||||||
attachUser(permalinkData.userId)
|
loggedInFlowNode.attachPermalinkData(permalinkData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun LoggedInFlowNode.attachPermalinkData(permalinkData: PermalinkData) {
|
||||||
|
when (permalinkData) {
|
||||||
|
is PermalinkData.FallbackLink -> Unit
|
||||||
|
is PermalinkData.RoomEmailInviteLink -> Unit
|
||||||
|
is PermalinkData.RoomLink -> {
|
||||||
|
attachRoom(
|
||||||
|
roomIdOrAlias = permalinkData.roomIdOrAlias,
|
||||||
|
trigger = JoinedRoom.Trigger.MobilePermalink,
|
||||||
|
serverNames = permalinkData.viaParameters,
|
||||||
|
eventId = permalinkData.eventId,
|
||||||
|
clearBackstack = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is PermalinkData.UserLink -> {
|
||||||
|
attachUser(permalinkData.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
||||||
Timber.d("Navigating to $deeplinkData")
|
Timber.d("Navigating to $deeplinkData")
|
||||||
attachSession(deeplinkData.sessionId)
|
attachSession(deeplinkData.sessionId).apply {
|
||||||
.apply {
|
when (deeplinkData) {
|
||||||
when (deeplinkData) {
|
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
|
||||||
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
|
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
|
||||||
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onOidcAction(oidcAction: OidcAction) {
|
private fun onOidcAction(oidcAction: OidcAction) {
|
||||||
oidcActionFlow.post(oidcAction)
|
oidcActionFlow.post(oidcAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [sessionId] will be null for permalink.
|
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
||||||
private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode {
|
// Ensure that the session is the latest one
|
||||||
// TODO handle multi-session
|
sessionStore.setLatestSession(sessionId.value)
|
||||||
return waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
|
return waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
|
||||||
navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId)
|
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
|
||||||
}
|
}.attachSession()
|
||||||
.attachSession()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ class IntentResolverTest {
|
||||||
@Test
|
@Test
|
||||||
fun `test resolve oidc`() {
|
fun `test resolve oidc`() {
|
||||||
val sut = createIntentResolver(
|
val sut = createIntentResolver(
|
||||||
oidcIntentResolverResult = { OidcAction.GoBack },
|
oidcIntentResolverResult = { OidcAction.GoBack() },
|
||||||
)
|
)
|
||||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||||
action = Intent.ACTION_VIEW
|
action = Intent.ACTION_VIEW
|
||||||
|
|
@ -120,7 +120,7 @@ class IntentResolverTest {
|
||||||
val result = sut.resolve(intent)
|
val result = sut.resolve(intent)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
ResolvedIntent.Oidc(
|
ResolvedIntent.Oidc(
|
||||||
oidcAction = OidcAction.GoBack
|
oidcAction = OidcAction.GoBack()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ dependencies {
|
||||||
testImplementation(projects.libraries.permissions.noop)
|
testImplementation(projects.libraries.permissions.noop)
|
||||||
testImplementation(projects.libraries.permissions.test)
|
testImplementation(projects.libraries.permissions.test)
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.home.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
|
class CurrentUserWithNeighborsBuilder {
|
||||||
|
/**
|
||||||
|
* Build a list of [MatrixUser] containing the current user. If there are other sessions, the list
|
||||||
|
* will contain 3 users, with the current user in the middle.
|
||||||
|
* If there is only one other session, the list will contain twice the other user, to allow cycling.
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
matrixUser: MatrixUser,
|
||||||
|
sessions: List<SessionData>,
|
||||||
|
): ImmutableList<MatrixUser> {
|
||||||
|
// Sort by position to always have the same order (not depending on last account usage)
|
||||||
|
return sessions.sortedBy { it.position }
|
||||||
|
.map {
|
||||||
|
if (it.userId == matrixUser.userId.value) {
|
||||||
|
// Always use the freshest profile for the current user
|
||||||
|
matrixUser
|
||||||
|
} else {
|
||||||
|
// Use the data from the DB
|
||||||
|
MatrixUser(
|
||||||
|
userId = UserId(it.userId),
|
||||||
|
displayName = it.userDisplayName,
|
||||||
|
avatarUrl = it.userAvatarUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { sessionList ->
|
||||||
|
// If the list has one item, there is no other session, return the list
|
||||||
|
when (sessionList.size) {
|
||||||
|
// Can happen when the user signs out (?)
|
||||||
|
0 -> listOf(matrixUser)
|
||||||
|
1 -> sessionList
|
||||||
|
else -> {
|
||||||
|
// Create a list with extra item at the start and end if necessary to have the current user in the middle
|
||||||
|
// If the list is [A, B, C, D] and the current user is A we want to return [D, A, B]
|
||||||
|
// If the current user is B, we want to return [A, B, C]
|
||||||
|
// If the current user is C, we want to return [B, C, D]
|
||||||
|
// If the current user is D, we want to return [C, D, A]
|
||||||
|
// Special case: if there are only two users, we want to return [B, A, B] or [A, B, A] to allows cycling
|
||||||
|
// between the two users.
|
||||||
|
val currentUserIndex = sessionList.indexOfFirst { it.userId == matrixUser.userId }
|
||||||
|
when (currentUserIndex) {
|
||||||
|
// This can happen when the user signs out.
|
||||||
|
// In this case, just return a singleton list with the current user.
|
||||||
|
-1 -> listOf(matrixUser)
|
||||||
|
0 -> listOf(sessionList.last()) + sessionList.take(2)
|
||||||
|
sessionList.lastIndex -> sessionList.takeLast(2) + sessionList.first()
|
||||||
|
else -> sessionList.slice(currentUserIndex - 1..currentUserIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toPersistentList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
package io.element.android.features.home.impl
|
package io.element.android.features.home.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
sealed interface HomeEvents {
|
sealed interface HomeEvents {
|
||||||
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents
|
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents
|
||||||
|
data class SwitchToAccount(val sessionId: SessionId) : HomeEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
|
|
@ -29,6 +30,10 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.indicator.api.IndicatorService
|
import io.element.android.libraries.indicator.api.IndicatorService
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
class HomePresenter(
|
class HomePresenter(
|
||||||
|
|
@ -41,10 +46,21 @@ class HomePresenter(
|
||||||
private val logoutPresenter: Presenter<DirectLogoutState>,
|
private val logoutPresenter: Presenter<DirectLogoutState>,
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
) : Presenter<HomeState> {
|
) : Presenter<HomeState> {
|
||||||
|
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): HomeState {
|
override fun present(): HomeState {
|
||||||
val matrixUser = client.userProfile.collectAsState()
|
val coroutineState = rememberCoroutineScope()
|
||||||
|
val matrixUser by client.userProfile.collectAsState()
|
||||||
|
val currentUserAndNeighbors by remember {
|
||||||
|
combine(
|
||||||
|
client.userProfile,
|
||||||
|
sessionStore.sessionsFlow(),
|
||||||
|
currentUserWithNeighborsBuilder::build,
|
||||||
|
)
|
||||||
|
}.collectAsState(initial = persistentListOf(matrixUser))
|
||||||
val isOnline by syncService.isOnline.collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
|
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
|
||||||
val roomListState = roomListPresenter.present()
|
val roomListState = roomListPresenter.present()
|
||||||
|
|
@ -71,6 +87,9 @@ class HomePresenter(
|
||||||
is HomeEvents.SelectHomeNavigationBarItem -> {
|
is HomeEvents.SelectHomeNavigationBarItem -> {
|
||||||
currentHomeNavigationBarItemOrdinal = event.item.ordinal
|
currentHomeNavigationBarItemOrdinal = event.item.ordinal
|
||||||
}
|
}
|
||||||
|
is HomeEvents.SwitchToAccount -> coroutineState.launch {
|
||||||
|
sessionStore.setLatestSession(event.sessionId.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +101,7 @@ class HomePresenter(
|
||||||
}
|
}
|
||||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
return HomeState(
|
return HomeState(
|
||||||
matrixUser = matrixUser.value,
|
currentUserAndNeighbors = currentUserAndNeighbors,
|
||||||
showAvatarIndicator = showAvatarIndicator,
|
showAvatarIndicator = showAvatarIndicator,
|
||||||
hasNetworkConnection = isOnline,
|
hasNetworkConnection = isOnline,
|
||||||
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
|
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,15 @@ import io.element.android.features.home.impl.spaces.HomeSpacesState
|
||||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class HomeState(
|
data class HomeState(
|
||||||
val matrixUser: MatrixUser,
|
/**
|
||||||
|
* The current user of this session, in case of multiple accounts, will contains 3 items, with the
|
||||||
|
* current user in the middle.
|
||||||
|
*/
|
||||||
|
val currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||||
val showAvatarIndicator: Boolean,
|
val showAvatarIndicator: Boolean,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
val currentHomeNavigationBarItem: HomeNavigationBarItem,
|
val currentHomeNavigationBarItem: HomeNavigationBarItem,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
open class HomeStateProvider : PreviewParameterProvider<HomeState> {
|
open class HomeStateProvider : PreviewParameterProvider<HomeState> {
|
||||||
override val values: Sequence<HomeState>
|
override val values: Sequence<HomeState>
|
||||||
|
|
@ -50,6 +51,7 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
|
||||||
|
|
||||||
internal fun aHomeState(
|
internal fun aHomeState(
|
||||||
matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
|
matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
|
||||||
|
currentUserAndNeighbors: List<MatrixUser> = listOf(matrixUser),
|
||||||
showAvatarIndicator: Boolean = false,
|
showAvatarIndicator: Boolean = false,
|
||||||
hasNetworkConnection: Boolean = true,
|
hasNetworkConnection: Boolean = true,
|
||||||
snackbarMessage: SnackbarMessage? = null,
|
snackbarMessage: SnackbarMessage? = null,
|
||||||
|
|
@ -61,7 +63,7 @@ internal fun aHomeState(
|
||||||
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||||
eventSink: (HomeEvents) -> Unit = {}
|
eventSink: (HomeEvents) -> Unit = {}
|
||||||
) = HomeState(
|
) = HomeState(
|
||||||
matrixUser = matrixUser,
|
currentUserAndNeighbors = currentUserAndNeighbors.toPersistentList(),
|
||||||
showAvatarIndicator = showAvatarIndicator,
|
showAvatarIndicator = showAvatarIndicator,
|
||||||
hasNetworkConnection = hasNetworkConnection,
|
hasNetworkConnection = hasNetworkConnection,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
|
|
|
||||||
|
|
@ -171,12 +171,15 @@ private fun HomeScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
RoomListTopBar(
|
RoomListTopBar(
|
||||||
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
|
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
|
||||||
matrixUser = state.matrixUser,
|
currentUserAndNeighbors = state.currentUserAndNeighbors,
|
||||||
showAvatarIndicator = state.showAvatarIndicator,
|
showAvatarIndicator = state.showAvatarIndicator,
|
||||||
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
|
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
|
||||||
onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) },
|
onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) },
|
||||||
onMenuActionClick = onMenuActionClick,
|
onMenuActionClick = onMenuActionClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onAccountSwitch = {
|
||||||
|
state.eventSink(HomeEvents.SwitchToAccount(it))
|
||||||
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
displayMenuItems = state.displayActions,
|
displayMenuItems = state.displayActions,
|
||||||
displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,
|
displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,25 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.pager.VerticalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
|
@ -41,7 +47,6 @@ import io.element.android.features.home.impl.filters.RoomListFiltersView
|
||||||
import io.element.android.features.home.impl.filters.aRoomListFiltersState
|
import io.element.android.features.home.impl.filters.aRoomListFiltersState
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
|
||||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||||
import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient
|
import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient
|
||||||
|
|
@ -57,23 +62,29 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
|
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListTopBar(
|
fun RoomListTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
matrixUser: MatrixUser,
|
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||||
showAvatarIndicator: Boolean,
|
showAvatarIndicator: Boolean,
|
||||||
areSearchResultsDisplayed: Boolean,
|
areSearchResultsDisplayed: Boolean,
|
||||||
onToggleSearch: () -> Unit,
|
onToggleSearch: () -> Unit,
|
||||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onAccountSwitch: (SessionId) -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
displayMenuItems: Boolean,
|
displayMenuItems: Boolean,
|
||||||
displayFilters: Boolean,
|
displayFilters: Boolean,
|
||||||
|
|
@ -83,10 +94,11 @@ fun RoomListTopBar(
|
||||||
) {
|
) {
|
||||||
DefaultRoomListTopBar(
|
DefaultRoomListTopBar(
|
||||||
title = title,
|
title = title,
|
||||||
matrixUser = matrixUser,
|
currentUserAndNeighbors = currentUserAndNeighbors,
|
||||||
showAvatarIndicator = showAvatarIndicator,
|
showAvatarIndicator = showAvatarIndicator,
|
||||||
areSearchResultsDisplayed = areSearchResultsDisplayed,
|
areSearchResultsDisplayed = areSearchResultsDisplayed,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onAccountSwitch = onAccountSwitch,
|
||||||
onSearchClick = onToggleSearch,
|
onSearchClick = onToggleSearch,
|
||||||
onMenuActionClick = onMenuActionClick,
|
onMenuActionClick = onMenuActionClick,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|
@ -102,11 +114,12 @@ fun RoomListTopBar(
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultRoomListTopBar(
|
private fun DefaultRoomListTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
matrixUser: MatrixUser,
|
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||||
showAvatarIndicator: Boolean,
|
showAvatarIndicator: Boolean,
|
||||||
areSearchResultsDisplayed: Boolean,
|
areSearchResultsDisplayed: Boolean,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onAccountSwitch: (SessionId) -> Unit,
|
||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||||
displayMenuItems: Boolean,
|
displayMenuItems: Boolean,
|
||||||
|
|
@ -116,12 +129,6 @@ private fun DefaultRoomListTopBar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val collapsedFraction = scrollBehavior.state.collapsedFraction
|
val collapsedFraction = scrollBehavior.state.collapsedFraction
|
||||||
val avatarData by remember(matrixUser) {
|
|
||||||
derivedStateOf {
|
|
||||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
|
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
|
||||||
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
|
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
|
||||||
|
|
@ -158,8 +165,9 @@ private fun DefaultRoomListTopBar(
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
NavigationIcon(
|
NavigationIcon(
|
||||||
avatarData = avatarData,
|
currentUserAndNeighbors = currentUserAndNeighbors,
|
||||||
showAvatarIndicator = showAvatarIndicator,
|
showAvatarIndicator = showAvatarIndicator,
|
||||||
|
onAccountSwitch = onAccountSwitch,
|
||||||
onClick = onOpenSettings,
|
onClick = onOpenSettings,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -247,19 +255,67 @@ private fun DefaultRoomListTopBar(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavigationIcon(
|
private fun NavigationIcon(
|
||||||
avatarData: AvatarData,
|
currentUserAndNeighbors: ImmutableList<MatrixUser>,
|
||||||
|
showAvatarIndicator: Boolean,
|
||||||
|
onAccountSwitch: (SessionId) -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (currentUserAndNeighbors.size == 1) {
|
||||||
|
AccountIcon(
|
||||||
|
matrixUser = currentUserAndNeighbors.single(),
|
||||||
|
isCurrentAccount = true,
|
||||||
|
showAvatarIndicator = showAvatarIndicator,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Render a vertical pager
|
||||||
|
val pagerState = rememberPagerState(initialPage = 1) { currentUserAndNeighbors.size }
|
||||||
|
// Listen to page changes and switch account if needed
|
||||||
|
val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch)
|
||||||
|
LaunchedEffect(pagerState) {
|
||||||
|
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||||
|
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.height(48.dp),
|
||||||
|
) { page ->
|
||||||
|
AccountIcon(
|
||||||
|
matrixUser = currentUserAndNeighbors[page],
|
||||||
|
isCurrentAccount = page == 1,
|
||||||
|
showAvatarIndicator = page == 1 && showAvatarIndicator,
|
||||||
|
onClick = if (page == 1) {
|
||||||
|
onClick
|
||||||
|
} else {
|
||||||
|
{}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountIcon(
|
||||||
|
matrixUser: MatrixUser,
|
||||||
|
isCurrentAccount: Boolean,
|
||||||
showAvatarIndicator: Boolean,
|
showAvatarIndicator: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.testTag(TestTags.homeScreenSettings),
|
modifier = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
) {
|
) {
|
||||||
Box {
|
Box {
|
||||||
|
val avatarData by remember(matrixUser) {
|
||||||
|
derivedStateOf {
|
||||||
|
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||||
|
}
|
||||||
|
}
|
||||||
Avatar(
|
Avatar(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
avatarType = AvatarType.User,
|
avatarType = AvatarType.User,
|
||||||
contentDescription = stringResource(CommonStrings.common_settings),
|
contentDescription = if (isCurrentAccount) stringResource(CommonStrings.common_settings) else null,
|
||||||
)
|
)
|
||||||
if (showAvatarIndicator) {
|
if (showAvatarIndicator) {
|
||||||
RedIndicatorAtom(
|
RedIndicatorAtom(
|
||||||
|
|
@ -276,11 +332,12 @@ private fun NavigationIcon(
|
||||||
internal fun DefaultRoomListTopBarPreview() = ElementPreview {
|
internal fun DefaultRoomListTopBarPreview() = ElementPreview {
|
||||||
DefaultRoomListTopBar(
|
DefaultRoomListTopBar(
|
||||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||||
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
|
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||||
showAvatarIndicator = false,
|
showAvatarIndicator = false,
|
||||||
areSearchResultsDisplayed = false,
|
areSearchResultsDisplayed = false,
|
||||||
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
|
onAccountSwitch = {},
|
||||||
onSearchClick = {},
|
onSearchClick = {},
|
||||||
displayMenuItems = true,
|
displayMenuItems = true,
|
||||||
displayFilters = true,
|
displayFilters = true,
|
||||||
|
|
@ -296,11 +353,33 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
|
||||||
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
|
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
|
||||||
DefaultRoomListTopBar(
|
DefaultRoomListTopBar(
|
||||||
title = stringResource(R.string.screen_roomlist_main_space_title),
|
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||||
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
|
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
|
||||||
showAvatarIndicator = true,
|
showAvatarIndicator = true,
|
||||||
areSearchResultsDisplayed = false,
|
areSearchResultsDisplayed = false,
|
||||||
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
|
onAccountSwitch = {},
|
||||||
|
onSearchClick = {},
|
||||||
|
displayMenuItems = true,
|
||||||
|
displayFilters = true,
|
||||||
|
filtersState = aRoomListFiltersState(),
|
||||||
|
canReportBug = true,
|
||||||
|
onMenuActionClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun DefaultRoomListTopBarMultiAccountPreview() = ElementPreview {
|
||||||
|
DefaultRoomListTopBar(
|
||||||
|
title = stringResource(R.string.screen_roomlist_main_space_title),
|
||||||
|
currentUserAndNeighbors = aMatrixUserList().take(3).toPersistentList(),
|
||||||
|
showAvatarIndicator = false,
|
||||||
|
areSearchResultsDisplayed = false,
|
||||||
|
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
||||||
|
onOpenSettings = {},
|
||||||
|
onAccountSwitch = {},
|
||||||
onSearchClick = {},
|
onSearchClick = {},
|
||||||
displayMenuItems = true,
|
displayMenuItems = true,
|
||||||
displayFilters = true,
|
displayFilters = true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.home.impl
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CurrentUserWithNeighborsBuilderTest {
|
||||||
|
@Test
|
||||||
|
fun `build on empty list returns current user`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser()
|
||||||
|
val list = listOf<SessionData>()
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result).containsExactly(matrixUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ensure that account are sorted by position`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
position = 3,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
position = 2,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_3.value,
|
||||||
|
position = 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID_3,
|
||||||
|
A_USER_ID_2,
|
||||||
|
A_USER_ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `if current user is not found, return a singleton with current user`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_3.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one account, will return a singleton`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two accounts, first is current, will return 3 items`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID_2,
|
||||||
|
A_USER_ID,
|
||||||
|
A_USER_ID_2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two accounts, second is current, will return 3 items`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID_2.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID,
|
||||||
|
A_USER_ID_2,
|
||||||
|
A_USER_ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three accounts, first is current, will return last current and next`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_3.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID_3,
|
||||||
|
A_USER_ID,
|
||||||
|
A_USER_ID_2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three accounts, second is current, will return first current and last`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID_2.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_3.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID,
|
||||||
|
A_USER_ID_2,
|
||||||
|
A_USER_ID_3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three accounts, current is last, will return middle, current and first`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(id = A_USER_ID_3.value)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_3.value,
|
||||||
|
),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result.map { it.userId }).containsExactly(
|
||||||
|
A_USER_ID,
|
||||||
|
A_USER_ID_2,
|
||||||
|
A_USER_ID_3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one account, will return data from matrix user and not from db`() {
|
||||||
|
val sut = CurrentUserWithNeighborsBuilder()
|
||||||
|
val matrixUser = aMatrixUser(
|
||||||
|
id = A_USER_ID.value,
|
||||||
|
displayName = "Bob",
|
||||||
|
avatarUrl = "avatarUrl",
|
||||||
|
)
|
||||||
|
val list = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
userDisplayName = "Outdated Bob",
|
||||||
|
userAvatarUrl = "outdatedAvatarUrl",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = sut.build(matrixUser, list)
|
||||||
|
assertThat(result).containsExactly(
|
||||||
|
MatrixUser(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
displayName = "Bob",
|
||||||
|
avatarUrl = "avatarUrl",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.tests.testutils.MutablePresenter
|
import io.element.android.tests.testutils.MutablePresenter
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
|
@ -54,17 +57,29 @@ class HomePresenterTest {
|
||||||
val presenter = createHomePresenter(
|
val presenter = createHomePresenter(
|
||||||
client = matrixClient,
|
client = matrixClient,
|
||||||
rageshakeFeatureAvailability = { flowOf(false) },
|
rageshakeFeatureAvailability = { flowOf(false) },
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = matrixClient.sessionId.value,
|
||||||
|
userDisplayName = null,
|
||||||
|
userAvatarUrl = null,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
|
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
|
||||||
|
MatrixUser(A_USER_ID, null, null)
|
||||||
|
)
|
||||||
assertThat(initialState.canReportBug).isFalse()
|
assertThat(initialState.canReportBug).isFalse()
|
||||||
|
skipItems(1)
|
||||||
val withUserState = awaitItem()
|
val withUserState = awaitItem()
|
||||||
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
|
assertThat(withUserState.currentUserAndNeighbors.first()).isEqualTo(
|
||||||
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
|
MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL)
|
||||||
assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
|
)
|
||||||
assertThat(withUserState.showAvatarIndicator).isFalse()
|
assertThat(withUserState.showAvatarIndicator).isFalse()
|
||||||
assertThat(withUserState.isSpaceFeatureEnabled).isFalse()
|
assertThat(withUserState.isSpaceFeatureEnabled).isFalse()
|
||||||
assertThat(withUserState.showNavigationBar).isFalse()
|
assertThat(withUserState.showNavigationBar).isFalse()
|
||||||
|
|
@ -75,6 +90,9 @@ class HomePresenterTest {
|
||||||
fun `present - can report bug`() = runTest {
|
fun `present - can report bug`() = runTest {
|
||||||
val presenter = createHomePresenter(
|
val presenter = createHomePresenter(
|
||||||
rageshakeFeatureAvailability = { flowOf(true) },
|
rageshakeFeatureAvailability = { flowOf(true) },
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -92,6 +110,9 @@ class HomePresenterTest {
|
||||||
featureFlagService = FakeFeatureFlagService(
|
featureFlagService = FakeFeatureFlagService(
|
||||||
initialState = mapOf(FeatureFlags.Space.key to true),
|
initialState = mapOf(FeatureFlags.Space.key to true),
|
||||||
),
|
),
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
@ -105,6 +126,9 @@ class HomePresenterTest {
|
||||||
val indicatorService = FakeIndicatorService()
|
val indicatorService = FakeIndicatorService()
|
||||||
val presenter = createHomePresenter(
|
val presenter = createHomePresenter(
|
||||||
indicatorService = indicatorService,
|
indicatorService = indicatorService,
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -124,19 +148,28 @@ class HomePresenterTest {
|
||||||
userAvatarUrl = null,
|
userAvatarUrl = null,
|
||||||
)
|
)
|
||||||
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
|
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
|
||||||
val presenter = createHomePresenter(client = matrixClient)
|
val presenter = createHomePresenter(
|
||||||
|
client = matrixClient,
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
|
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
|
||||||
// No new state is coming
|
// No new state is coming
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - NavigationBar change`() = runTest {
|
fun `present - NavigationBar change`() = runTest {
|
||||||
val presenter = createHomePresenter()
|
val presenter = createHomePresenter(
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -152,6 +185,9 @@ class HomePresenterTest {
|
||||||
fun `present - NavigationBar is hidden when the last space is left`() = runTest {
|
fun `present - NavigationBar is hidden when the last space is left`() = runTest {
|
||||||
val homeSpacesPresenter = MutablePresenter(aHomeSpacesState())
|
val homeSpacesPresenter = MutablePresenter(aHomeSpacesState())
|
||||||
val presenter = createHomePresenter(
|
val presenter = createHomePresenter(
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
featureFlagService = FakeFeatureFlagService(
|
featureFlagService = FakeFeatureFlagService(
|
||||||
initialState = mapOf(FeatureFlags.Space.key to true),
|
initialState = mapOf(FeatureFlags.Space.key to true),
|
||||||
),
|
),
|
||||||
|
|
@ -185,6 +221,7 @@ internal fun createHomePresenter(
|
||||||
indicatorService: IndicatorService = FakeIndicatorService(),
|
indicatorService: IndicatorService = FakeIndicatorService(),
|
||||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
|
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
) = HomePresenter(
|
) = HomePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
syncService = syncService,
|
syncService = syncService,
|
||||||
|
|
@ -195,4 +232,5 @@ internal fun createHomePresenter(
|
||||||
homeSpacesPresenter = homeSpacesPresenter,
|
homeSpacesPresenter = homeSpacesPresenter,
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
|
sessionStore = sessionStore,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ dependencies {
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.permissions.api)
|
implementation(projects.libraries.permissions.api)
|
||||||
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
implementation(projects.libraries.qrcode)
|
implementation(projects.libraries.qrcode)
|
||||||
implementation(projects.libraries.oidc.api)
|
implementation(projects.libraries.oidc.api)
|
||||||
implementation(projects.libraries.uiUtils)
|
implementation(projects.libraries.uiUtils)
|
||||||
|
|
@ -56,5 +57,6 @@ dependencies {
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.oidc.test)
|
testImplementation(projects.libraries.oidc.test)
|
||||||
testImplementation(projects.libraries.permissions.test)
|
testImplementation(projects.libraries.permissions.test)
|
||||||
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
testImplementation(projects.libraries.wellknown.test)
|
testImplementation(projects.libraries.wellknown.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class LoginFlowNode(
|
||||||
// by pressing back or by closing the Custom Chrome Tab.
|
// by pressing back or by closing the Custom Chrome Tab.
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
delay(5000)
|
delay(5000)
|
||||||
oidcActionFlow.post(OidcAction.GoBack)
|
oidcActionFlow.post(OidcAction.GoBack(toUnblock = true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,9 +94,14 @@ class LoginHelper(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onOidcAction(oidcAction: OidcAction) {
|
private suspend fun onOidcAction(oidcAction: OidcAction) {
|
||||||
|
if (oidcAction is OidcAction.GoBack && oidcAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
|
||||||
|
// Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode.
|
||||||
|
// This can happen if there is an error, for instance attempt to login again on the same account.
|
||||||
|
return
|
||||||
|
}
|
||||||
loginModeState.value = AsyncData.Loading()
|
loginModeState.value = AsyncData.Loading()
|
||||||
when (oidcAction) {
|
when (oidcAction) {
|
||||||
OidcAction.GoBack -> {
|
is OidcAction.GoBack -> {
|
||||||
authenticationService.cancelOidcLogin()
|
authenticationService.cancelOidcLogin()
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
loginModeState.value = AsyncData.Uninitialized
|
loginModeState.value = AsyncData.Uninitialized
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
import io.element.android.features.login.impl.error.ChangeServerErrorProvider
|
|
||||||
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
import io.element.android.libraries.androidutils.system.openGooglePlay
|
import io.element.android.libraries.androidutils.system.openGooglePlay
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
@ -23,6 +22,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
||||||
|
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
|
@ -89,6 +89,12 @@ fun LoginModeView(
|
||||||
onSubmit = onClearError,
|
onSubmit = onClearError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is AuthenticationException.AccountAlreadyLoggedIn -> {
|
||||||
|
ErrorDialog(
|
||||||
|
content = stringResource(CommonStrings.error_account_already_logged_in, error.message.orEmpty()),
|
||||||
|
onSubmit = onClearError,
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
content = stringResource(CommonStrings.error_unknown),
|
content = stringResource(CommonStrings.error_unknown),
|
||||||
|
|
@ -113,7 +119,7 @@ fun LoginModeView(
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) {
|
internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) {
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
LoginModeView(
|
LoginModeView(
|
||||||
loginMode = AsyncData.Failure(error),
|
loginMode = AsyncData.Failure(error),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.login
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.login.impl.error.ChangeServerErrorProvider
|
||||||
|
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||||
|
|
||||||
|
class LoginModeViewErrorProvider : PreviewParameterProvider<Exception> {
|
||||||
|
override val values: Sequence<Exception>
|
||||||
|
get() = ChangeServerErrorProvider().values +
|
||||||
|
AuthenticationException.AccountAlreadyLoggedIn("@alice:matrix.org")
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,7 @@ class OnBoardingNode(
|
||||||
onNeedLoginPassword = ::onLoginPasswordNeeded,
|
onNeedLoginPassword = ::onLoginPasswordNeeded,
|
||||||
onLearnMoreClick = { openLearnMorePage(context) },
|
onLearnMoreClick = { openLearnMorePage(context) },
|
||||||
onCreateAccountContinue = ::onCreateAccountContinue,
|
onCreateAccountContinue = ::onCreateAccountContinue,
|
||||||
|
onBackClick = ::navigateUp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import io.element.android.features.login.impl.login.LoginHelper
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
|
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
|
|
@ -38,6 +39,7 @@ class OnBoardingPresenter(
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
private val loginHelper: LoginHelper,
|
private val loginHelper: LoginHelper,
|
||||||
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
) : Presenter<OnBoardingState> {
|
) : Presenter<OnBoardingState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -86,6 +88,10 @@ class OnBoardingPresenter(
|
||||||
val onBoardingLogoResId = remember {
|
val onBoardingLogoResId = remember {
|
||||||
onBoardingLogoResIdProvider.get()
|
onBoardingLogoResIdProvider.get()
|
||||||
}
|
}
|
||||||
|
val isAddingAccount by produceState(initialValue = false) {
|
||||||
|
// We are adding an account if there is at least one session already stored
|
||||||
|
value = sessionStore.getAllSessions().isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
val loginMode by loginHelper.collectLoginMode()
|
val loginMode by loginHelper.collectLoginMode()
|
||||||
|
|
||||||
|
|
@ -109,6 +115,7 @@ class OnBoardingPresenter(
|
||||||
}
|
}
|
||||||
|
|
||||||
return OnBoardingState(
|
return OnBoardingState(
|
||||||
|
isAddingAccount = isAddingAccount,
|
||||||
productionApplicationName = buildMeta.productionApplicationName,
|
productionApplicationName = buildMeta.productionApplicationName,
|
||||||
defaultAccountProvider = defaultAccountProvider,
|
defaultAccountProvider = defaultAccountProvider,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import io.element.android.features.login.impl.login.LoginMode
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
data class OnBoardingState(
|
data class OnBoardingState(
|
||||||
|
val isAddingAccount: Boolean,
|
||||||
val productionApplicationName: String,
|
val productionApplicationName: String,
|
||||||
val defaultAccountProvider: String?,
|
val defaultAccountProvider: String?,
|
||||||
val mustChooseAccountProvider: Boolean,
|
val mustChooseAccountProvider: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,16 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
||||||
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
|
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
|
||||||
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true),
|
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true),
|
||||||
anOnBoardingState(customLogoResId = R.drawable.sample_background),
|
anOnBoardingState(customLogoResId = R.drawable.sample_background),
|
||||||
|
anOnBoardingState(
|
||||||
|
isAddingAccount = true,
|
||||||
|
canLoginWithQrCode = true,
|
||||||
|
canCreateAccount = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anOnBoardingState(
|
fun anOnBoardingState(
|
||||||
|
isAddingAccount: Boolean = false,
|
||||||
productionApplicationName: String = "Element",
|
productionApplicationName: String = "Element",
|
||||||
defaultAccountProvider: String? = null,
|
defaultAccountProvider: String? = null,
|
||||||
mustChooseAccountProvider: Boolean = false,
|
mustChooseAccountProvider: Boolean = false,
|
||||||
|
|
@ -39,6 +45,7 @@ fun anOnBoardingState(
|
||||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||||
eventSink: (OnBoardingEvents) -> Unit = {},
|
eventSink: (OnBoardingEvents) -> Unit = {},
|
||||||
) = OnBoardingState(
|
) = OnBoardingState(
|
||||||
|
isAddingAccount = isAddingAccount,
|
||||||
productionApplicationName = productionApplicationName,
|
productionApplicationName = productionApplicationName,
|
||||||
defaultAccountProvider = defaultAccountProvider,
|
defaultAccountProvider = defaultAccountProvider,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
|
@ -58,6 +60,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
fun OnBoardingView(
|
fun OnBoardingView(
|
||||||
state: OnBoardingState,
|
state: OnBoardingState,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
onSignInWithQrCode: () -> Unit,
|
onSignInWithQrCode: () -> Unit,
|
||||||
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
|
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
|
||||||
onCreateAccount: () -> Unit,
|
onCreateAccount: () -> Unit,
|
||||||
|
|
@ -67,6 +70,52 @@ fun OnBoardingView(
|
||||||
onCreateAccountContinue: (url: String) -> Unit,
|
onCreateAccountContinue: (url: String) -> Unit,
|
||||||
onReportProblem: () -> Unit,
|
onReportProblem: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val loginView = @Composable {
|
||||||
|
LoginModeView(
|
||||||
|
loginMode = state.loginMode,
|
||||||
|
onClearError = {
|
||||||
|
state.eventSink(OnBoardingEvents.ClearError)
|
||||||
|
},
|
||||||
|
onLearnMoreClick = onLearnMoreClick,
|
||||||
|
onOidcDetails = onOidcDetails,
|
||||||
|
onNeedLoginPassword = onNeedLoginPassword,
|
||||||
|
onCreateAccountContinue = onCreateAccountContinue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val buttons = @Composable {
|
||||||
|
OnBoardingButtons(
|
||||||
|
state = state,
|
||||||
|
onSignInWithQrCode = onSignInWithQrCode,
|
||||||
|
onSignIn = onSignIn,
|
||||||
|
onCreateAccount = onCreateAccount,
|
||||||
|
onReportProblem = onReportProblem,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isAddingAccount) {
|
||||||
|
AddOtherAccountScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
loginView = loginView,
|
||||||
|
buttons = buttons,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AddFirstAccountScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
loginView = loginView,
|
||||||
|
buttons = buttons,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddFirstAccountScaffold(
|
||||||
|
state: OnBoardingState,
|
||||||
|
loginView: @Composable () -> Unit,
|
||||||
|
buttons: @Composable () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
OnBoardingPage(
|
OnBoardingPage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -79,29 +128,31 @@ fun OnBoardingView(
|
||||||
} else {
|
} else {
|
||||||
OnBoardingContent(state = state)
|
OnBoardingContent(state = state)
|
||||||
}
|
}
|
||||||
LoginModeView(
|
loginView()
|
||||||
loginMode = state.loginMode,
|
|
||||||
onClearError = {
|
|
||||||
state.eventSink(OnBoardingEvents.ClearError)
|
|
||||||
},
|
|
||||||
onLearnMoreClick = onLearnMoreClick,
|
|
||||||
onOidcDetails = onOidcDetails,
|
|
||||||
onNeedLoginPassword = onNeedLoginPassword,
|
|
||||||
onCreateAccountContinue = onCreateAccountContinue,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
footer = {
|
footer = {
|
||||||
OnBoardingButtons(
|
buttons()
|
||||||
state = state,
|
|
||||||
onSignInWithQrCode = onSignInWithQrCode,
|
|
||||||
onSignIn = onSignIn,
|
|
||||||
onCreateAccount = onCreateAccount,
|
|
||||||
onReportProblem = onReportProblem,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddOtherAccountScaffold(
|
||||||
|
loginView: @Composable () -> Unit,
|
||||||
|
buttons: @Composable () -> Unit,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FlowStepPage(
|
||||||
|
modifier = modifier,
|
||||||
|
title = stringResource(CommonStrings.common_add_account),
|
||||||
|
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
|
||||||
|
buttons = { buttons() },
|
||||||
|
content = loginView,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnBoardingContent(state: OnBoardingState) {
|
private fun OnBoardingContent(state: OnBoardingState) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -226,27 +277,29 @@ private fun OnBoardingButtons(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.canReportBug) {
|
if (state.isAddingAccount.not()) {
|
||||||
// Add a report problem text button. Use a Text since we need a special theme here.
|
if (state.canReportBug) {
|
||||||
Text(
|
// Add a report problem text button. Use a Text since we need a special theme here.
|
||||||
modifier = Modifier
|
Text(
|
||||||
.clickable(onClick = onReportProblem)
|
modifier = Modifier
|
||||||
.padding(16.dp),
|
.clickable(onClick = onReportProblem)
|
||||||
text = stringResource(id = CommonStrings.common_report_a_problem),
|
.padding(16.dp),
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
text = stringResource(id = CommonStrings.common_report_a_problem),
|
||||||
color = ElementTheme.colors.textSecondary,
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
)
|
color = ElementTheme.colors.textSecondary,
|
||||||
} else {
|
)
|
||||||
Text(
|
} else {
|
||||||
modifier = Modifier
|
Text(
|
||||||
.clickable {
|
modifier = Modifier
|
||||||
state.eventSink(OnBoardingEvents.OnVersionClick)
|
.clickable {
|
||||||
}
|
state.eventSink(OnBoardingEvents.OnVersionClick)
|
||||||
.padding(16.dp),
|
}
|
||||||
text = stringResource(id = R.string.screen_onboarding_app_version, state.version),
|
.padding(16.dp),
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
text = stringResource(id = R.string.screen_onboarding_app_version, state.version),
|
||||||
color = ElementTheme.colors.textSecondary,
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
)
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +311,7 @@ internal fun OnBoardingViewPreview(
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
OnBoardingView(
|
OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
|
onBackClick = {},
|
||||||
onSignInWithQrCode = {},
|
onSignInWithQrCode = {},
|
||||||
onSignIn = {},
|
onSignIn = {},
|
||||||
onCreateAccount = {},
|
onCreateAccount = {},
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ class ConfirmAccountProviderPresenterTest {
|
||||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
||||||
authenticationService.givenOidcCancelError(AN_EXCEPTION)
|
authenticationService.givenOidcCancelError(AN_EXCEPTION)
|
||||||
defaultOidcActionFlow.post(OidcAction.GoBack)
|
defaultOidcActionFlow.post(OidcAction.GoBack())
|
||||||
val cancelFailureState = awaitItem()
|
val cancelFailureState = awaitItem()
|
||||||
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +144,30 @@ class ConfirmAccountProviderPresenterTest {
|
||||||
assertThat(successState.submitEnabled).isFalse()
|
assertThat(successState.submitEnabled).isFalse()
|
||||||
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
|
||||||
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
|
||||||
defaultOidcActionFlow.post(OidcAction.GoBack)
|
defaultOidcActionFlow.post(OidcAction.GoBack())
|
||||||
|
val cancelFinalState = awaitItem()
|
||||||
|
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - oidc - cancel to unblock`() = runTest {
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService()
|
||||||
|
val defaultOidcActionFlow = FakeOidcActionFlow()
|
||||||
|
val presenter = createConfirmAccountProviderPresenter(
|
||||||
|
matrixAuthenticationService = authenticationService,
|
||||||
|
defaultOidcActionFlow = defaultOidcActionFlow,
|
||||||
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.submitEnabled).isTrue()
|
||||||
|
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
|
||||||
|
defaultOidcActionFlow.post(OidcAction.GoBack(toUnblock = true))
|
||||||
val cancelFinalState = awaitItem()
|
val cancelFinalState = awaitItem()
|
||||||
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||||
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
|
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
|
@ -79,10 +82,27 @@ class OnBoardingPresenterTest {
|
||||||
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
||||||
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
||||||
assertThat(initialState.canReportBug).isFalse()
|
assertThat(initialState.canReportBug).isFalse()
|
||||||
|
assertThat(initialState.isAddingAccount).isFalse()
|
||||||
assertThat(awaitItem().canLoginWithQrCode).isTrue()
|
assertThat(awaitItem().canLoginWithQrCode).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state adding account`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.isAddingAccount).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - on boarding logo`() = runTest {
|
fun `present - on boarding logo`() = runTest {
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
|
|
@ -236,6 +256,7 @@ private fun createPresenter(
|
||||||
rageshakeFeatureAvailability: () -> Flow<Boolean> = { flowOf(true) },
|
rageshakeFeatureAvailability: () -> Flow<Boolean> = { flowOf(true) },
|
||||||
loginHelper: LoginHelper = createLoginHelper(),
|
loginHelper: LoginHelper = createLoginHelper(),
|
||||||
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
|
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
) = OnBoardingPresenter(
|
) = OnBoardingPresenter(
|
||||||
params = params,
|
params = params,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
|
|
@ -247,6 +268,7 @@ private fun createPresenter(
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
loginHelper = loginHelper,
|
loginHelper = loginHelper,
|
||||||
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
||||||
|
sessionStore = sessionStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createLoginHelper(
|
fun createLoginHelper(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.ensureCalledOnce
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||||
|
import io.element.android.tests.testutils.pressBack
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
|
|
@ -50,6 +51,21 @@ class OnboardingViewTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when can go back - clicking on back calls the expected callback`() {
|
||||||
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setOnboardingView(
|
||||||
|
state = anOnBoardingState(
|
||||||
|
isAddingAccount = true,
|
||||||
|
eventSink = eventSink,
|
||||||
|
),
|
||||||
|
onBackClick = callback,
|
||||||
|
)
|
||||||
|
rule.pressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
|
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
|
||||||
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
|
||||||
|
|
@ -235,6 +251,7 @@ class OnboardingViewTest {
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
|
||||||
state: OnBoardingState,
|
state: OnBoardingState,
|
||||||
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
|
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
|
||||||
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onCreateAccount: () -> Unit = EnsureNeverCalled(),
|
onCreateAccount: () -> Unit = EnsureNeverCalled(),
|
||||||
|
|
@ -247,6 +264,7 @@ class OnboardingViewTest {
|
||||||
setContent {
|
setContent {
|
||||||
OnBoardingView(
|
OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
|
onBackClick = onBackClick,
|
||||||
onSignInWithQrCode = onSignInWithQrCode,
|
onSignInWithQrCode = onSignInWithQrCode,
|
||||||
onSignIn = onSignIn,
|
onSignIn = onSignIn,
|
||||||
onCreateAccount = onCreateAccount,
|
onCreateAccount = onCreateAccount,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
interface PreferencesEntryPoint : FeatureEntryPoint {
|
interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||||
|
|
@ -41,9 +40,10 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
|
fun onAddAccount()
|
||||||
fun onOpenBugReport()
|
fun onOpenBugReport()
|
||||||
fun onSecureBackupClick()
|
fun onSecureBackupClick()
|
||||||
fun onOpenRoomNotificationSettings(roomId: RoomId)
|
fun onOpenRoomNotificationSettings(roomId: RoomId)
|
||||||
fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId)
|
fun navigateTo(roomId: RoomId, eventId: EventId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ dependencies {
|
||||||
testImplementation(projects.features.logout.test)
|
testImplementation(projects.features.logout.test)
|
||||||
testImplementation(projects.libraries.indicator.test)
|
testImplementation(projects.libraries.indicator.test)
|
||||||
testImplementation(projects.libraries.pushproviders.test)
|
testImplementation(projects.libraries.pushproviders.test)
|
||||||
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
||||||
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
||||||
|
|
@ -117,6 +116,10 @@ class PreferencesFlowNode(
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Root -> {
|
NavTarget.Root -> {
|
||||||
val callback = object : PreferencesRootNode.Callback {
|
val callback = object : PreferencesRootNode.Callback {
|
||||||
|
override fun onAddAccount() {
|
||||||
|
plugins<PreferencesEntryPoint.Callback>().forEach { it.onAddAccount() }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpenBugReport() {
|
override fun onOpenBugReport() {
|
||||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenBugReport() }
|
plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenBugReport() }
|
||||||
}
|
}
|
||||||
|
|
@ -226,8 +229,8 @@ class PreferencesFlowNode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
|
override fun navigateTo(roomId: RoomId, eventId: EventId) {
|
||||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.navigateTo(sessionId, roomId, eventId) }
|
plugins<PreferencesEntryPoint.Callback>().forEach { it.navigateTo(roomId, eventId) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.root
|
package io.element.android.features.preferences.impl.root
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
sealed interface PreferencesRootEvents {
|
sealed interface PreferencesRootEvents {
|
||||||
data object OnVersionInfoClick : PreferencesRootEvents
|
data object OnVersionInfoClick : PreferencesRootEvents
|
||||||
|
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class PreferencesRootNode(
|
||||||
private val directLogoutView: DirectLogoutView,
|
private val directLogoutView: DirectLogoutView,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
|
fun onAddAccount()
|
||||||
fun onOpenBugReport()
|
fun onOpenBugReport()
|
||||||
fun onSecureBackupClick()
|
fun onSecureBackupClick()
|
||||||
fun onOpenAnalytics()
|
fun onOpenAnalytics()
|
||||||
|
|
@ -48,6 +49,10 @@ class PreferencesRootNode(
|
||||||
fun onOpenAccountDeactivation()
|
fun onOpenAccountDeactivation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onAddAccount() {
|
||||||
|
plugins<Callback>().forEach { it.onAddAccount() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun onOpenBugReport() {
|
private fun onOpenBugReport() {
|
||||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +124,7 @@ class PreferencesRootNode(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = this::navigateUp,
|
onBackClick = this::navigateUp,
|
||||||
|
onAddAccountClick = this::onAddAccount,
|
||||||
onOpenRageShake = this::onOpenBugReport,
|
onOpenRageShake = this::onOpenBugReport,
|
||||||
onOpenAnalytics = this::onOpenAnalytics,
|
onOpenAnalytics = this::onOpenAnalytics,
|
||||||
onOpenAbout = this::onOpenAbout,
|
onOpenAbout = this::onOpenAbout,
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,21 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.indicator.api.IndicatorService
|
import io.element.android.libraries.indicator.api.IndicatorService
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -45,6 +53,8 @@ class PreferencesRootPresenter(
|
||||||
private val directLogoutPresenter: Presenter<DirectLogoutState>,
|
private val directLogoutPresenter: Presenter<DirectLogoutState>,
|
||||||
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
|
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
|
||||||
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
) : Presenter<PreferencesRootState> {
|
) : Presenter<PreferencesRootState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): PreferencesRootState {
|
override fun present(): PreferencesRootState {
|
||||||
|
|
@ -55,6 +65,25 @@ class PreferencesRootPresenter(
|
||||||
matrixClient.getUserProfile()
|
matrixClient.getUserProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isMultiAccountEnabled by remember {
|
||||||
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
||||||
|
}.collectAsState(initial = false)
|
||||||
|
|
||||||
|
val otherSessions by remember {
|
||||||
|
sessionStore.sessionsFlow().map { list ->
|
||||||
|
list
|
||||||
|
.filter { it.userId != matrixClient.sessionId.value }
|
||||||
|
.map {
|
||||||
|
MatrixUser(
|
||||||
|
userId = UserId(it.userId),
|
||||||
|
displayName = it.userDisplayName,
|
||||||
|
avatarUrl = it.userAvatarUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toPersistentList()
|
||||||
|
}
|
||||||
|
}.collectAsState(initial = persistentListOf())
|
||||||
|
|
||||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() }
|
val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() }
|
||||||
|
|
||||||
|
|
@ -96,6 +125,9 @@ class PreferencesRootPresenter(
|
||||||
is PreferencesRootEvents.OnVersionInfoClick -> {
|
is PreferencesRootEvents.OnVersionInfoClick -> {
|
||||||
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
|
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
|
||||||
}
|
}
|
||||||
|
is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch {
|
||||||
|
sessionStore.setLatestSession(event.sessionId.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,6 +135,8 @@ class PreferencesRootPresenter(
|
||||||
myUser = matrixUser.value,
|
myUser = matrixUser.value,
|
||||||
version = versionFormatter.get(),
|
version = versionFormatter.get(),
|
||||||
deviceId = matrixClient.deviceId,
|
deviceId = matrixClient.deviceId,
|
||||||
|
isMultiAccountEnabled = isMultiAccountEnabled,
|
||||||
|
otherSessions = otherSessions,
|
||||||
showSecureBackup = !canVerifyUserSession,
|
showSecureBackup = !canVerifyUserSession,
|
||||||
showSecureBackupBadge = showSecureBackupIndicator,
|
showSecureBackupBadge = showSecureBackupIndicator,
|
||||||
accountManagementUrl = accountManagementUrl.value,
|
accountManagementUrl = accountManagementUrl.value,
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@ import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class PreferencesRootState(
|
data class PreferencesRootState(
|
||||||
val myUser: MatrixUser,
|
val myUser: MatrixUser,
|
||||||
val version: String,
|
val version: String,
|
||||||
val deviceId: DeviceId?,
|
val deviceId: DeviceId?,
|
||||||
|
val isMultiAccountEnabled: Boolean,
|
||||||
|
val otherSessions: ImmutableList<MatrixUser>,
|
||||||
val showSecureBackup: Boolean,
|
val showSecureBackup: Boolean,
|
||||||
val showSecureBackupBadge: Boolean,
|
val showSecureBackupBadge: Boolean,
|
||||||
val accountManagementUrl: String?,
|
val accountManagementUrl: String?,
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,20 @@ import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
fun aPreferencesRootState(
|
fun aPreferencesRootState(
|
||||||
myUser: MatrixUser,
|
myUser: MatrixUser = aMatrixUser(),
|
||||||
|
otherSessions: List<MatrixUser> = emptyList(),
|
||||||
eventSink: (PreferencesRootEvents) -> Unit = { _ -> },
|
eventSink: (PreferencesRootEvents) -> Unit = { _ -> },
|
||||||
) = PreferencesRootState(
|
) = PreferencesRootState(
|
||||||
myUser = myUser,
|
myUser = myUser,
|
||||||
version = "Version 1.1 (1)",
|
version = "Version 1.1 (1)",
|
||||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||||
|
isMultiAccountEnabled = true,
|
||||||
|
otherSessions = otherSessions.toPersistentList(),
|
||||||
showSecureBackup = true,
|
showSecureBackup = true,
|
||||||
showSecureBackupBadge = true,
|
showSecureBackupBadge = true,
|
||||||
accountManagementUrl = "aUrl",
|
accountManagementUrl = "aUrl",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.preferences.impl.root
|
package io.element.android.features.preferences.impl.root
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -23,11 +24,14 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
|
|
@ -38,12 +42,15 @@ import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbar
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
|
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
|
||||||
|
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PreferencesRootView(
|
fun PreferencesRootView(
|
||||||
state: PreferencesRootState,
|
state: PreferencesRootState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
|
onAddAccountClick: () -> Unit,
|
||||||
onSecureBackupClick: () -> Unit,
|
onSecureBackupClick: () -> Unit,
|
||||||
onManageAccountClick: (url: String) -> Unit,
|
onManageAccountClick: (url: String) -> Unit,
|
||||||
onOpenAnalytics: () -> Unit,
|
onOpenAnalytics: () -> Unit,
|
||||||
|
|
@ -74,7 +81,12 @@ fun PreferencesRootView(
|
||||||
},
|
},
|
||||||
user = state.myUser,
|
user = state.myUser,
|
||||||
)
|
)
|
||||||
|
if (state.isMultiAccountEnabled) {
|
||||||
|
MultiAccountSection(
|
||||||
|
state = state,
|
||||||
|
onAddAccountClick = onAddAccountClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
// 'Manage my app' section
|
// 'Manage my app' section
|
||||||
ManageAppSection(
|
ManageAppSection(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
@ -114,6 +126,38 @@ fun PreferencesRootView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.MultiAccountSection(
|
||||||
|
state: PreferencesRootState,
|
||||||
|
onAddAccountClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 8.dp,
|
||||||
|
color = ElementTheme.colors.bgSubtleSecondary,
|
||||||
|
)
|
||||||
|
state.otherSessions.forEach { matrixUser ->
|
||||||
|
MatrixUserRow(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId))
|
||||||
|
},
|
||||||
|
matrixUser = matrixUser,
|
||||||
|
avatarSize = AvatarSize.AccountItem,
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(CommonStrings.common_add_another_account))
|
||||||
|
},
|
||||||
|
onClick = onAddAccountClick,
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 8.dp,
|
||||||
|
color = ElementTheme.colors.bgSubtleSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.ManageAppSection(
|
private fun ColumnScope.ManageAppSection(
|
||||||
state: PreferencesRootState,
|
state: PreferencesRootState,
|
||||||
|
|
@ -287,6 +331,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
||||||
PreferencesRootView(
|
PreferencesRootView(
|
||||||
state = aPreferencesRootState(myUser = matrixUser),
|
state = aPreferencesRootState(myUser = matrixUser),
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
|
onAddAccountClick = {},
|
||||||
onOpenAnalytics = {},
|
onOpenAnalytics = {},
|
||||||
onOpenRageShake = {},
|
onOpenRageShake = {},
|
||||||
onOpenDeveloperSettings = {},
|
onOpenDeveloperSettings = {},
|
||||||
|
|
@ -302,3 +347,16 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
||||||
onDeactivateClick = {},
|
onDeactivateClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun MultiAccountSectionPreview() = ElementPreview {
|
||||||
|
Column {
|
||||||
|
MultiAccountSection(
|
||||||
|
state = aPreferencesRootState(
|
||||||
|
otherSessions = aMatrixUserList(),
|
||||||
|
),
|
||||||
|
onAddAccountClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import io.element.android.features.logout.api.LogoutEntryPoint
|
||||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
||||||
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
@ -64,10 +63,11 @@ class DefaultPreferencesEntryPointTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : PreferencesEntryPoint.Callback {
|
val callback = object : PreferencesEntryPoint.Callback {
|
||||||
|
override fun onAddAccount() = lambdaError()
|
||||||
override fun onOpenBugReport() = lambdaError()
|
override fun onOpenBugReport() = lambdaError()
|
||||||
override fun onSecureBackupClick() = lambdaError()
|
override fun onSecureBackupClick() = lambdaError()
|
||||||
override fun onOpenRoomNotificationSettings(roomId: RoomId) = lambdaError()
|
override fun onOpenRoomNotificationSettings(roomId: RoomId) = lambdaError()
|
||||||
override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) = lambdaError()
|
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
|
||||||
}
|
}
|
||||||
val params = PreferencesEntryPoint.Params(
|
val params = PreferencesEntryPoint.Params(
|
||||||
initialElement = PreferencesEntryPoint.InitialTarget.NotificationSettings,
|
initialElement = PreferencesEntryPoint.InitialTarget.NotificationSettings,
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,23 @@ import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsP
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.core.meta.BuildType
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.indicator.api.IndicatorService
|
import io.element.android.libraries.indicator.api.IndicatorService
|
||||||
import io.element.android.libraries.indicator.test.FakeIndicatorService
|
import io.element.android.libraries.indicator.test.FakeIndicatorService
|
||||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
|
@ -61,6 +69,8 @@ class PreferencesRootPresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assertThat(initialState.version).isEqualTo("A Version")
|
assertThat(initialState.version).isEqualTo("A Version")
|
||||||
|
assertThat(initialState.isMultiAccountEnabled).isFalse()
|
||||||
|
assertThat(initialState.otherSessions).isEmpty()
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.myUser).isEqualTo(
|
assertThat(loadedState.myUser).isEqualTo(
|
||||||
MatrixUser(
|
MatrixUser(
|
||||||
|
|
@ -174,6 +184,34 @@ class PreferencesRootPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - multiple accounts`() = runTest {
|
||||||
|
createPresenter(
|
||||||
|
matrixClient = FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
canDeactivateAccountResult = { true },
|
||||||
|
),
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.MultiAccount.key to true)
|
||||||
|
),
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(sessionId = A_SESSION_ID.value),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_SESSION_ID_2.value,
|
||||||
|
userDisplayName = "Bob",
|
||||||
|
userAvatarUrl = "avatarUrl",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).test {
|
||||||
|
val state = awaitFirstItem()
|
||||||
|
assertThat(state.isMultiAccountEnabled).isTrue()
|
||||||
|
assertThat(state.otherSessions).hasSize(1)
|
||||||
|
assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
return awaitItem()
|
return awaitItem()
|
||||||
|
|
@ -185,6 +223,8 @@ class PreferencesRootPresenterTest {
|
||||||
showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
|
showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
|
||||||
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) },
|
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) },
|
||||||
indicatorService: IndicatorService = FakeIndicatorService(),
|
indicatorService: IndicatorService = FakeIndicatorService(),
|
||||||
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
) = PreferencesRootPresenter(
|
) = PreferencesRootPresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
sessionVerificationService = sessionVerificationService,
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
|
@ -195,5 +235,7 @@ class PreferencesRootPresenterTest {
|
||||||
directLogoutPresenter = { aDirectLogoutState() },
|
directLogoutPresenter = { aDirectLogoutState() },
|
||||||
showDeveloperSettingsProvider = showDeveloperSettingsProvider,
|
showDeveloperSettingsProvider = showDeveloperSettingsProvider,
|
||||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
|
sessionStore = sessionStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,5 +43,9 @@ private fun aSessionData(
|
||||||
passphrase = null,
|
passphrase = null,
|
||||||
sessionPath = "/a/path/to/a/session",
|
sessionPath = "/a/path/to/a/session",
|
||||||
cachePath = "/a/path/to/a/cache",
|
cachePath = "/a/path/to/a/cache",
|
||||||
|
position = 0,
|
||||||
|
lastUsageIndex = 0,
|
||||||
|
userDisplayName = null,
|
||||||
|
userAvatarUrl = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
libraries/accountselect/api/build.gradle.kts
Normal file
18
libraries/accountselect/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.accountselect.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.api
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
interface AccountSelectEntryPoint : FeatureEntryPoint {
|
||||||
|
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||||
|
|
||||||
|
interface NodeBuilder {
|
||||||
|
fun callback(callback: Callback): NodeBuilder
|
||||||
|
fun build(): Node
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onSelectAccount(sessionId: SessionId)
|
||||||
|
fun onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
35
libraries/accountselect/impl/build.gradle.kts
Normal file
35
libraries/accountselect/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.accountselect.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDependencyInjection()
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
|
implementation(projects.libraries.designsystem)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
|
api(projects.libraries.accountselect.api)
|
||||||
|
|
||||||
|
testCommonDependencies(libs)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class AccountSelectNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: AccountSelectPresenter,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
private val callbacks = plugins.filterIsInstance<AccountSelectEntryPoint.Callback>()
|
||||||
|
|
||||||
|
private fun onDismiss() {
|
||||||
|
callbacks.forEach { it.onCancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSelectAccount(sessionId: SessionId) {
|
||||||
|
callbacks.forEach { it.onSelectAccount(sessionId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
AccountSelectView(
|
||||||
|
state = state,
|
||||||
|
onDismiss = ::onDismiss,
|
||||||
|
onSelectAccount = ::onSelectAccount,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class AccountSelectPresenter(
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
|
) : Presenter<AccountSelectState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): AccountSelectState {
|
||||||
|
val accounts by produceState(persistentListOf()) {
|
||||||
|
// Do not use sessionStore.sessionsFlow() to not make it change when an account is selected.
|
||||||
|
value = sessionStore.getAllSessions()
|
||||||
|
.map {
|
||||||
|
MatrixUser(
|
||||||
|
userId = UserId(it.userId),
|
||||||
|
displayName = it.userDisplayName,
|
||||||
|
avatarUrl = it.userAvatarUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toPersistentList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return AccountSelectState(
|
||||||
|
accounts = accounts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
data class AccountSelectState(
|
||||||
|
val accounts: ImmutableList<MatrixUser>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
|
open class AccountSelectStateProvider : PreviewParameterProvider<AccountSelectState> {
|
||||||
|
override val values: Sequence<AccountSelectState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
anAccountSelectState(),
|
||||||
|
anAccountSelectState(accounts = aMatrixUserList()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun anAccountSelectState(
|
||||||
|
accounts: List<MatrixUser> = listOf(),
|
||||||
|
) = AccountSelectState(
|
||||||
|
accounts = accounts.toPersistentList(),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Suppress("MultipleEmitters") // False positive
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountSelectView(
|
||||||
|
state: AccountSelectState,
|
||||||
|
onSelectAccount: (SessionId) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = { onDismiss() })
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
titleStr = stringResource(CommonStrings.common_select_account),
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onClick = { onDismiss() })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.consumeWindowInsets(paddingValues)
|
||||||
|
) {
|
||||||
|
LazyColumn {
|
||||||
|
items(state.accounts, key = { it.userId }) { matrixUser ->
|
||||||
|
Column {
|
||||||
|
MatrixUserRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onSelectAccount(matrixUser.userId)
|
||||||
|
}
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
matrixUser = matrixUser,
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun AccountSelectViewPreview(@PreviewParameter(AccountSelectStateProvider::class) state: AccountSelectState) = ElementPreview {
|
||||||
|
AccountSelectView(
|
||||||
|
state = state,
|
||||||
|
onSelectAccount = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
@Inject
|
||||||
|
class DefaultAccountSelectEntryPoint : AccountSelectEntryPoint {
|
||||||
|
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): AccountSelectEntryPoint.NodeBuilder {
|
||||||
|
val plugins = ArrayList<Plugin>()
|
||||||
|
|
||||||
|
return object : AccountSelectEntryPoint.NodeBuilder {
|
||||||
|
override fun callback(callback: AccountSelectEntryPoint.Callback): AccountSelectEntryPoint.NodeBuilder {
|
||||||
|
plugins += callback
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun build(): Node {
|
||||||
|
return parentNode.createNode<AccountSelectNode>(buildContext, plugins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AccountSelectPresenterTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = createAccountSelectPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.accounts).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - multiple accounts case`() = runTest {
|
||||||
|
val presenter = createAccountSelectPresenter(
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(sessionId = A_SESSION_ID.value),
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_SESSION_ID_2.value,
|
||||||
|
userDisplayName = "Bob",
|
||||||
|
userAvatarUrl = "avatarUrl",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.accounts).hasSize(2)
|
||||||
|
val firstAccount = initialState.accounts[0]
|
||||||
|
assertThat(firstAccount).isEqualTo(
|
||||||
|
MatrixUser(
|
||||||
|
userId = A_SESSION_ID,
|
||||||
|
displayName = null,
|
||||||
|
avatarUrl = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val secondAccount = initialState.accounts[1]
|
||||||
|
assertThat(secondAccount).isEqualTo(
|
||||||
|
MatrixUser(
|
||||||
|
userId = A_SESSION_ID_2,
|
||||||
|
displayName = "Bob",
|
||||||
|
avatarUrl = "avatarUrl",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun createAccountSelectPresenter(
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
|
) = AccountSelectPresenter(
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.accountselect.impl
|
||||||
|
|
||||||
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultAccountSelectEntryPointTest {
|
||||||
|
@get:Rule
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test node builder`() {
|
||||||
|
val entryPoint = DefaultAccountSelectEntryPoint()
|
||||||
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
|
AccountSelectNode(
|
||||||
|
buildContext = buildContext,
|
||||||
|
plugins = plugins,
|
||||||
|
presenter = createAccountSelectPresenter(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val callback = object : AccountSelectEntryPoint.Callback {
|
||||||
|
override fun onSelectAccount(sessionId: SessionId) = lambdaError()
|
||||||
|
override fun onCancel() = lambdaError()
|
||||||
|
}
|
||||||
|
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
|
||||||
|
.callback(callback)
|
||||||
|
.build()
|
||||||
|
assertThat(result).isInstanceOf(AccountSelectNode::class.java)
|
||||||
|
assertThat(result.plugins).contains(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.architecture.appyx
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.animation.core.Transition
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
|
||||||
|
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [ModifierTransitionHandler] that delegates the creation of the modifier to another handler
|
||||||
|
* based on the [NavTarget]. The idea is to allow different transitions for different [NavTarget]s.
|
||||||
|
*/
|
||||||
|
class DelegateTransitionHandler<NavTarget, State>(
|
||||||
|
private val handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
|
||||||
|
) : ModifierTransitionHandler<NavTarget, State>() {
|
||||||
|
@SuppressLint("ModifierFactoryExtensionFunction")
|
||||||
|
override fun createModifier(modifier: Modifier, transition: Transition<State>, descriptor: TransitionDescriptor<NavTarget, State>): Modifier {
|
||||||
|
return handlerProvider(descriptor.element).createModifier(modifier, transition, descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <NavTarget, State> rememberDelegateTransitionHandler(
|
||||||
|
handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
|
||||||
|
): ModifierTransitionHandler<NavTarget, State> =
|
||||||
|
remember(handlerProvider) { DelegateTransitionHandler(handlerProvider = handlerProvider) }
|
||||||
|
|
@ -73,4 +73,6 @@ enum class AvatarSize(val dp: Dp) {
|
||||||
RoomPreviewInviter(56.dp),
|
RoomPreviewInviter(56.dp),
|
||||||
SpaceMember(24.dp),
|
SpaceMember(24.dp),
|
||||||
LeaveSpaceRoom(32.dp),
|
LeaveSpaceRoom(32.dp),
|
||||||
|
|
||||||
|
AccountItem(32.dp),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,5 +99,13 @@ enum class FeatureFlags(
|
||||||
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
|
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
|
||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
)
|
),
|
||||||
|
MultiAccount(
|
||||||
|
key = "feature.multi_account",
|
||||||
|
title = "Multi accounts",
|
||||||
|
description = "Allow the application to connect to multiple accounts at the same time." +
|
||||||
|
"\n\nWARNING: this feature is EXPERIMENTAL and UNSTABLE.",
|
||||||
|
defaultValue = { false },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.libraries.matrix.api.auth
|
package io.element.android.libraries.matrix.api.auth
|
||||||
|
|
||||||
sealed class AuthenticationException(message: String) : Exception(message) {
|
sealed class AuthenticationException(message: String) : Exception(message) {
|
||||||
|
class AccountAlreadyLoggedIn(userId: String) : AuthenticationException(userId)
|
||||||
class InvalidServerName(message: String) : AuthenticationException(message)
|
class InvalidServerName(message: String) : AuthenticationException(message)
|
||||||
class SlidingSyncVersion(message: String) : AuthenticationException(message)
|
class SlidingSyncVersion(message: String) : AuthenticationException(message)
|
||||||
class Oidc(message: String) : AuthenticationException(message)
|
class Oidc(message: String) : AuthenticationException(message)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.libraries.matrix.api.permalink
|
package io.element.android.libraries.matrix.api.permalink
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -15,13 +16,15 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This sealed class represents all the permalink cases.
|
* This sealed class represents all the permalink cases.
|
||||||
* You don't have to instantiate yourself but should use [PermalinkParser] instead.
|
* You don't have to instantiate yourself but should use [PermalinkParser] instead.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface PermalinkData {
|
@Parcelize
|
||||||
|
sealed interface PermalinkData : Parcelable {
|
||||||
data class RoomLink(
|
data class RoomLink(
|
||||||
val roomIdOrAlias: RoomIdOrAlias,
|
val roomIdOrAlias: RoomIdOrAlias,
|
||||||
val eventId: EventId? = null,
|
val eventId: EventId? = null,
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,6 @@ class RustMatrixClient(
|
||||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||||
MatrixUser(
|
MatrixUser(
|
||||||
userId = sessionId,
|
userId = sessionId,
|
||||||
// TODO cache for displayName?
|
|
||||||
displayName = null,
|
displayName = null,
|
||||||
avatarUrl = null,
|
avatarUrl = null,
|
||||||
)
|
)
|
||||||
|
|
@ -264,6 +263,16 @@ class RustMatrixClient(
|
||||||
// Start notification settings
|
// Start notification settings
|
||||||
notificationSettingsService.start()
|
notificationSettingsService.start()
|
||||||
|
|
||||||
|
// Update the user profile in the session store if needed
|
||||||
|
sessionStore.getSession(sessionId.value)?.let { sessionData ->
|
||||||
|
_userProfile.emit(
|
||||||
|
MatrixUser(
|
||||||
|
userId = sessionId,
|
||||||
|
displayName = sessionData.userDisplayName,
|
||||||
|
avatarUrl = sessionData.userAvatarUrl,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
// Force a refresh of the profile
|
// Force a refresh of the profile
|
||||||
getUserProfile()
|
getUserProfile()
|
||||||
}
|
}
|
||||||
|
|
@ -399,7 +408,15 @@ class RustMatrixClient(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
||||||
.onSuccess { _userProfile.tryEmit(it) }
|
.onSuccess { matrixUser ->
|
||||||
|
_userProfile.emit(matrixUser)
|
||||||
|
// Also update our session storage
|
||||||
|
sessionStore.updateUserProfile(
|
||||||
|
sessionId = sessionId.value,
|
||||||
|
displayName = matrixUser.displayName,
|
||||||
|
avatarUrl = matrixUser.avatarUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
|
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
|
||||||
withContext(sessionDispatcher) {
|
withContext(sessionDispatcher) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import org.matrix.rustcomponents.sdk.OidcException
|
||||||
fun Throwable.mapAuthenticationException(): AuthenticationException {
|
fun Throwable.mapAuthenticationException(): AuthenticationException {
|
||||||
val message = this.message ?: "Unknown error"
|
val message = this.message ?: "Unknown error"
|
||||||
return when (this) {
|
return when (this) {
|
||||||
|
is AuthenticationException -> this
|
||||||
is ClientBuildException -> when (this) {
|
is ClientBuildException -> when (this) {
|
||||||
is ClientBuildException.Generic -> AuthenticationException.Generic(message)
|
is ClientBuildException.Generic -> AuthenticationException.Generic(message)
|
||||||
is ClientBuildException.InvalidServerName -> AuthenticationException.InvalidServerName(message)
|
is ClientBuildException.InvalidServerName -> AuthenticationException.InvalidServerName(message)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.extensions.mapFailure
|
import io.element.android.libraries.core.extensions.mapFailure
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
@ -139,6 +140,8 @@ class RustMatrixAuthenticationService(
|
||||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||||
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
||||||
client.login(username, password, "Element X Android", null)
|
client.login(username, password, "Element X Android", null)
|
||||||
|
// Ensure that the user is not already logged in with the same account
|
||||||
|
ensureNotAlreadyLoggedIn(client)
|
||||||
val sessionData = client.session()
|
val sessionData = client.session()
|
||||||
.toSessionData(
|
.toSessionData(
|
||||||
isTokenValid = true,
|
isTokenValid = true,
|
||||||
|
|
@ -227,17 +230,19 @@ class RustMatrixAuthenticationService(
|
||||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||||
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
||||||
client.loginWithOidcCallback(callbackUrl)
|
client.loginWithOidcCallback(callbackUrl)
|
||||||
|
|
||||||
|
// Free the pending data since we won't use it to abort the flow anymore
|
||||||
|
pendingOAuthAuthorizationData?.close()
|
||||||
|
pendingOAuthAuthorizationData = null
|
||||||
|
|
||||||
|
// Ensure that the user is not already logged in with the same account
|
||||||
|
ensureNotAlreadyLoggedIn(client)
|
||||||
val sessionData = client.session().toSessionData(
|
val sessionData = client.session().toSessionData(
|
||||||
isTokenValid = true,
|
isTokenValid = true,
|
||||||
loginType = LoginType.OIDC,
|
loginType = LoginType.OIDC,
|
||||||
passphrase = pendingPassphrase,
|
passphrase = pendingPassphrase,
|
||||||
sessionPaths = currentSessionPaths,
|
sessionPaths = currentSessionPaths,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Free the pending data since we won't use it to abort the flow anymore
|
|
||||||
pendingOAuthAuthorizationData?.close()
|
|
||||||
pendingOAuthAuthorizationData = null
|
|
||||||
|
|
||||||
val matrixClient = rustMatrixClientFactory.create(client)
|
val matrixClient = rustMatrixClientFactory.create(client)
|
||||||
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
|
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
|
||||||
sessionStore.addSession(sessionData)
|
sessionStore.addSession(sessionData)
|
||||||
|
|
@ -253,6 +258,21 @@ class RustMatrixAuthenticationService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(AuthenticationException.AccountAlreadyLoggedIn::class)
|
||||||
|
private suspend fun ensureNotAlreadyLoggedIn(client: Client) {
|
||||||
|
val newUserId = client.userId()
|
||||||
|
val accountAlreadyLoggedIn = sessionStore.getAllSessions().any {
|
||||||
|
it.userId == newUserId
|
||||||
|
}
|
||||||
|
if (accountAlreadyLoggedIn) {
|
||||||
|
// Sign out the client, ignoring any error
|
||||||
|
runCatchingExceptions {
|
||||||
|
client.logout()
|
||||||
|
}
|
||||||
|
throw AuthenticationException.AccountAlreadyLoggedIn(newUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
|
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData
|
val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData
|
||||||
|
|
@ -275,7 +295,8 @@ class RustMatrixAuthenticationService(
|
||||||
oidcConfiguration = oidcConfiguration,
|
oidcConfiguration = oidcConfiguration,
|
||||||
progressListener = progressListener,
|
progressListener = progressListener,
|
||||||
)
|
)
|
||||||
|
// Ensure that the user is not already logged in with the same account
|
||||||
|
ensureNotAlreadyLoggedIn(client)
|
||||||
val sessionData = client.session()
|
val sessionData = client.session()
|
||||||
.toSessionData(
|
.toSessionData(
|
||||||
isTokenValid = true,
|
isTokenValid = true,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ internal fun Session.toSessionData(
|
||||||
passphrase = passphrase,
|
passphrase = passphrase,
|
||||||
sessionPath = sessionPaths.fileDirectory.absolutePath,
|
sessionPath = sessionPaths.fileDirectory.absolutePath,
|
||||||
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
||||||
|
// Note: position and lastUsageIndex will be set by the SessionStore when adding the session
|
||||||
|
position = 0,
|
||||||
|
lastUsageIndex = 0,
|
||||||
|
userDisplayName = null,
|
||||||
|
userAvatarUrl = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun ExternalSession.toSessionData(
|
internal fun ExternalSession.toSessionData(
|
||||||
|
|
@ -55,4 +60,8 @@ internal fun ExternalSession.toSessionData(
|
||||||
passphrase = passphrase,
|
passphrase = passphrase,
|
||||||
sessionPath = sessionPaths.fileDirectory.absolutePath,
|
sessionPath = sessionPaths.fileDirectory.absolutePath,
|
||||||
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
||||||
|
position = 0,
|
||||||
|
lastUsageIndex = 0,
|
||||||
|
userDisplayName = null,
|
||||||
|
userAvatarUrl = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ class RustMatrixClientFactoryTest {
|
||||||
fun TestScope.createRustMatrixClientFactory(
|
fun TestScope.createRustMatrixClientFactory(
|
||||||
baseDirectory: File = File("/base"),
|
baseDirectory: File = File("/base"),
|
||||||
cacheDirectory: File = File("/cache"),
|
cacheDirectory: File = File("/cache"),
|
||||||
sessionStore: SessionStore = InMemorySessionStore(),
|
sessionStore: SessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||||
) = RustMatrixClientFactory(
|
) = RustMatrixClientFactory(
|
||||||
baseDirectory = baseDirectory,
|
baseDirectory = baseDirectory,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl
|
package io.element.android.libraries.matrix.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
|
@ -12,17 +14,24 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
|
||||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory
|
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory
|
||||||
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
|
import org.matrix.rustcomponents.sdk.UserProfile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class RustMatrixClientTest {
|
class RustMatrixClientTest {
|
||||||
|
|
@ -51,9 +60,46 @@ class RustMatrixClientTest {
|
||||||
client.destroy()
|
client.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retrieving the UserProfile updates the database`() = runTest {
|
||||||
|
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> }
|
||||||
|
val sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
userDisplayName = null,
|
||||||
|
userAvatarUrl = null,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
updateUserProfileResult = updateUserProfileResult,
|
||||||
|
)
|
||||||
|
val client = createRustMatrixClient(
|
||||||
|
client = FakeFfiClient(
|
||||||
|
getProfileResult = { userId ->
|
||||||
|
UserProfile(
|
||||||
|
userId = userId,
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
avatarUrl = AN_AVATAR_URL,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
updateUserProfileResult.assertions().isCalledOnce()
|
||||||
|
.with(
|
||||||
|
value(A_USER_ID.value),
|
||||||
|
value(A_USER_NAME),
|
||||||
|
value(AN_AVATAR_URL),
|
||||||
|
)
|
||||||
|
client.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createRustMatrixClient(
|
private fun TestScope.createRustMatrixClient(
|
||||||
client: Client = FakeFfiClient(),
|
client: Client = FakeFfiClient(),
|
||||||
sessionStore: SessionStore = InMemorySessionStore(),
|
sessionStore: SessionStore = InMemorySessionStore(
|
||||||
|
updateUserProfileResult = { _, _, _ -> },
|
||||||
|
),
|
||||||
) = RustMatrixClient(
|
) = RustMatrixClient(
|
||||||
innerClient = client,
|
innerClient = client,
|
||||||
baseDirectory = File(""),
|
baseDirectory = File(""),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class FakeFfiClient(
|
||||||
private val session: Session = aRustSession(),
|
private val session: Session = aRustSession(),
|
||||||
private val clearCachesResult: () -> Unit = { lambdaError() },
|
private val clearCachesResult: () -> Unit = { lambdaError() },
|
||||||
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
|
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
|
||||||
|
private val getProfileResult: (String) -> UserProfile = { UserProfile(userId = userId, displayName = null, avatarUrl = null) },
|
||||||
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
|
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
|
||||||
private val closeResult: () -> Unit = {},
|
private val closeResult: () -> Unit = {},
|
||||||
) : Client(NoPointer) {
|
) : Client(NoPointer) {
|
||||||
|
|
@ -79,7 +80,7 @@ class FakeFfiClient(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getProfile(userId: String): UserProfile {
|
override suspend fun getProfile(userId: String): UserProfile {
|
||||||
return UserProfile(userId = userId, displayName = null, avatarUrl = null)
|
return getProfileResult(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun homeserverLoginDetails(): HomeserverLoginDetails {
|
override suspend fun homeserverLoginDetails(): HomeserverLoginDetails {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,5 @@ class FakeFfiClientBuilder(
|
||||||
override fun username(username: String) = this
|
override fun username(username: String) = this
|
||||||
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
|
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
|
||||||
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
|
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
|
||||||
|
|
||||||
override suspend fun build() = buildResult()
|
override suspend fun build() = buildResult()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@
|
||||||
package io.element.android.libraries.oidc.api
|
package io.element.android.libraries.oidc.api
|
||||||
|
|
||||||
sealed interface OidcAction {
|
sealed interface OidcAction {
|
||||||
data object GoBack : OidcAction
|
data class GoBack(val toUnblock: Boolean = false) : OidcAction
|
||||||
data class Success(val url: String) : OidcAction
|
data class Success(val url: String) : OidcAction
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class DefaultOidcUrlParser(
|
||||||
*/
|
*/
|
||||||
override fun parse(url: String): OidcAction? {
|
override fun parse(url: String): OidcAction? {
|
||||||
if (url.startsWith(oidcRedirectUrlProvider.provide()).not()) return null
|
if (url.startsWith(oidcRedirectUrlProvider.provide()).not()) return null
|
||||||
if (url.contains("error=access_denied")) return OidcAction.GoBack
|
if (url.contains("error=access_denied")) return OidcAction.GoBack()
|
||||||
if (url.contains("code=")) return OidcAction.Success(url)
|
if (url.contains("code=")) return OidcAction.Success(url)
|
||||||
|
|
||||||
// Other case not supported, let's crash the app for now
|
// Other case not supported, let's crash the app for now
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,10 @@ class DefaultOidcActionFlowTest {
|
||||||
data.add(action)
|
data.add(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sut.post(OidcAction.GoBack)
|
sut.post(OidcAction.GoBack())
|
||||||
delay(1)
|
delay(1)
|
||||||
sut.reset()
|
sut.reset()
|
||||||
delay(1)
|
delay(1)
|
||||||
assertThat(data).containsExactly(OidcAction.GoBack, null)
|
assertThat(data).containsExactly(OidcAction.GoBack(), null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class DefaultOidcIntentResolverTest {
|
||||||
data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
|
data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
|
||||||
}
|
}
|
||||||
val result = sut.resolve(intent)
|
val result = sut.resolve(intent)
|
||||||
assertThat(result).isEqualTo(OidcAction.GoBack)
|
assertThat(result).isEqualTo(OidcAction.GoBack())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class DefaultOidcUrlParserTest {
|
||||||
fun `test cancel url`() {
|
fun `test cancel url`() {
|
||||||
val sut = createDefaultOidcUrlParser()
|
val sut = createDefaultOidcUrlParser()
|
||||||
val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO"
|
val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO"
|
||||||
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
|
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,12 @@ data class SessionData(
|
||||||
val sessionPath: String,
|
val sessionPath: String,
|
||||||
/** The path to the cache data stored for the session in the filesystem. */
|
/** The path to the cache data stored for the session in the filesystem. */
|
||||||
val cachePath: String,
|
val cachePath: String,
|
||||||
|
/** The position, to be able to order account. */
|
||||||
|
val position: Long,
|
||||||
|
/** The index of the last date of session usage. */
|
||||||
|
val lastUsageIndex: Long,
|
||||||
|
/** The optional display name of the user. */
|
||||||
|
val userDisplayName: String?,
|
||||||
|
/** The optional avatar URL of the user. */
|
||||||
|
val userAvatarUrl: String?,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,22 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
interface SessionStore {
|
interface SessionStore {
|
||||||
|
/**
|
||||||
|
* A flow emitting the current logged in state.
|
||||||
|
* If there is at least one session, the state is [LoggedInState.LoggedIn] with the latest used session.
|
||||||
|
* If there is no session, the state is [LoggedInState.NotLoggedIn].
|
||||||
|
*/
|
||||||
fun loggedInStateFlow(): Flow<LoggedInState>
|
fun loggedInStateFlow(): Flow<LoggedInState>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a flow of all sessions ordered by last usage descending.
|
||||||
|
*/
|
||||||
fun sessionsFlow(): Flow<List<SessionData>>
|
fun sessionsFlow(): Flow<List<SessionData>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new session. If other sessions exist, the new one will be set as the latest used one, and
|
||||||
|
* the added session position will be set to a value higher than the other session positions.
|
||||||
|
*/
|
||||||
suspend fun addSession(sessionData: SessionData)
|
suspend fun addSession(sessionData: SessionData)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,9 +34,35 @@ interface SessionStore {
|
||||||
* No op if userId is not found in DB.
|
* No op if userId is not found in DB.
|
||||||
*/
|
*/
|
||||||
suspend fun updateData(sessionData: SessionData)
|
suspend fun updateData(sessionData: SessionData)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user profile info of the session matching the userId.
|
||||||
|
*/
|
||||||
|
suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the session data matching the userId, or null if not found.
|
||||||
|
*/
|
||||||
suspend fun getSession(sessionId: String): SessionData?
|
suspend fun getSession(sessionId: String): SessionData?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sessions ordered by last usage descending.
|
||||||
|
*/
|
||||||
suspend fun getAllSessions(): List<SessionData>
|
suspend fun getAllSessions(): List<SessionData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest session, or null if no session exists.
|
||||||
|
*/
|
||||||
suspend fun getLatestSession(): SessionData?
|
suspend fun getLatestSession(): SessionData?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the session with [sessionId] as the latest used one.
|
||||||
|
*/
|
||||||
|
suspend fun setLatestSession(sessionId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the session matching the sessionId.
|
||||||
|
*/
|
||||||
suspend fun removeSession(sessionId: String)
|
suspend fun removeSession(sessionId: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ dependencies {
|
||||||
sqldelight {
|
sqldelight {
|
||||||
databases {
|
databases {
|
||||||
create("SessionDatabase") {
|
create("SessionDatabase") {
|
||||||
// https://cashapp.github.io/sqldelight/2.0.0/android_sqlite/migrations/
|
// https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/
|
||||||
// To generate a .db file from your latest schema, run this task
|
// To generate a .db file from your latest schema, run this task
|
||||||
// ./gradlew generateDebugSessionDatabaseSchema
|
// ./gradlew generateDebugSessionDatabaseSchema
|
||||||
// Test migration by running
|
// Test migration by running
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class DatabaseSessionStore(
|
||||||
private val sessionDataMutex = Mutex()
|
private val sessionDataMutex = Mutex()
|
||||||
|
|
||||||
override fun loggedInStateFlow(): Flow<LoggedInState> {
|
override fun loggedInStateFlow(): Flow<LoggedInState> {
|
||||||
return database.sessionDataQueries.selectFirst()
|
return database.sessionDataQueries.selectLatest()
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.mapToOneOrNull(dispatchers.io)
|
.mapToOneOrNull(dispatchers.io)
|
||||||
.map {
|
.map {
|
||||||
|
|
@ -51,7 +51,17 @@ class DatabaseSessionStore(
|
||||||
|
|
||||||
override suspend fun addSession(sessionData: SessionData) {
|
override suspend fun addSession(sessionData: SessionData) {
|
||||||
sessionDataMutex.withLock {
|
sessionDataMutex.withLock {
|
||||||
database.sessionDataQueries.insertSessionData(sessionData.toDbModel())
|
val lastUsageIndex = getLastUsageIndex()
|
||||||
|
database.sessionDataQueries.insertSessionData(
|
||||||
|
sessionData
|
||||||
|
.copy(
|
||||||
|
// position value does not really matter, so just use lastUsageIndex + 1 to ensure that
|
||||||
|
// the value is always greater than value of any existing account
|
||||||
|
position = lastUsageIndex + 1,
|
||||||
|
lastUsageIndex = lastUsageIndex + 1,
|
||||||
|
)
|
||||||
|
.toDbModel()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,18 +75,71 @@ class DatabaseSessionStore(
|
||||||
Timber.e("User ${sessionData.userId} not found in session database")
|
Timber.e("User ${sessionData.userId} not found in session database")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Copy new data from SDK, but keep login timestamp
|
// Copy new data from SDK, but keep application data
|
||||||
database.sessionDataQueries.updateSession(
|
database.sessionDataQueries.updateSession(
|
||||||
sessionData.copy(
|
sessionData.copy(
|
||||||
loginTimestamp = result.loginTimestamp,
|
loginTimestamp = result.loginTimestamp,
|
||||||
|
position = result.position,
|
||||||
|
lastUsageIndex = result.lastUsageIndex,
|
||||||
|
userDisplayName = result.userDisplayName,
|
||||||
|
userAvatarUrl = result.userAvatarUrl,
|
||||||
).toDbModel()
|
).toDbModel()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
|
||||||
|
sessionDataMutex.withLock {
|
||||||
|
val result = database.sessionDataQueries.selectByUserId(sessionId)
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?.toApiModel()
|
||||||
|
if (result == null) {
|
||||||
|
Timber.e("User $sessionId not found in session database")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.sessionDataQueries.updateSession(
|
||||||
|
result.copy(
|
||||||
|
userDisplayName = displayName,
|
||||||
|
userAvatarUrl = avatarUrl,
|
||||||
|
).toDbModel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setLatestSession(sessionId: String) {
|
||||||
|
val latestSession = getLatestSession()
|
||||||
|
if (latestSession?.userId == sessionId) {
|
||||||
|
// Already the latest session
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastUsageIndex = latestSession?.lastUsageIndex ?: 0
|
||||||
|
val result = database.sessionDataQueries.selectByUserId(sessionId)
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?.toApiModel()
|
||||||
|
if (result == null) {
|
||||||
|
Timber.e("User $sessionId not found in session database")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionDataMutex.withLock {
|
||||||
|
// Update lastUsageIndex of the session
|
||||||
|
database.sessionDataQueries.updateSession(
|
||||||
|
result.copy(
|
||||||
|
lastUsageIndex = lastUsageIndex + 1,
|
||||||
|
).toDbModel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLastUsageIndex(): Long {
|
||||||
|
return database.sessionDataQueries.selectLatest()
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?.lastUsageIndex
|
||||||
|
?: -1L
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getLatestSession(): SessionData? {
|
override suspend fun getLatestSession(): SessionData? {
|
||||||
return sessionDataMutex.withLock {
|
return sessionDataMutex.withLock {
|
||||||
database.sessionDataQueries.selectFirst()
|
database.sessionDataQueries.selectLatest()
|
||||||
.executeAsOneOrNull()
|
.executeAsOneOrNull()
|
||||||
?.toApiModel()
|
?.toApiModel()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ internal fun SessionData.toDbModel(): DbSessionData {
|
||||||
passphrase = passphrase,
|
passphrase = passphrase,
|
||||||
sessionPath = sessionPath,
|
sessionPath = sessionPath,
|
||||||
cachePath = cachePath,
|
cachePath = cachePath,
|
||||||
|
position = position,
|
||||||
|
lastUsageIndex = lastUsageIndex,
|
||||||
|
userDisplayName = userDisplayName,
|
||||||
|
userAvatarUrl = userAvatarUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,5 +49,9 @@ internal fun DbSessionData.toApiModel(): SessionData {
|
||||||
passphrase = passphrase,
|
passphrase = passphrase,
|
||||||
sessionPath = sessionPath,
|
sessionPath = sessionPath,
|
||||||
cachePath = cachePath,
|
cachePath = cachePath,
|
||||||
|
position = position,
|
||||||
|
lastUsageIndex = lastUsageIndex,
|
||||||
|
userDisplayName = userDisplayName,
|
||||||
|
userAvatarUrl = userAvatarUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -27,15 +27,25 @@ CREATE TABLE SessionData (
|
||||||
-- added in version 6
|
-- added in version 6
|
||||||
sessionPath TEXT NOT NULL DEFAULT "",
|
sessionPath TEXT NOT NULL DEFAULT "",
|
||||||
-- added in version 9
|
-- added in version 9
|
||||||
cachePath TEXT NOT NULL DEFAULT ""
|
cachePath TEXT NOT NULL DEFAULT "",
|
||||||
|
-- added in version 10
|
||||||
|
-- position, to be able to sort account by session creation date
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- index of the last usage session. Each time the current session change, the index of the current
|
||||||
|
-- session is incremented to the max value + 1 so it becomes the current session
|
||||||
|
lastUsageIndex INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- user display name
|
||||||
|
userDisplayName TEXT,
|
||||||
|
-- user avatar url
|
||||||
|
userAvatarUrl TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
selectFirst:
|
selectLatest:
|
||||||
SELECT * FROM SessionData LIMIT 1;
|
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1;
|
||||||
|
|
||||||
selectAll:
|
selectAll:
|
||||||
SELECT * FROM SessionData;
|
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC;
|
||||||
|
|
||||||
selectByUserId:
|
selectByUserId:
|
||||||
SELECT * FROM SessionData WHERE userId = ?;
|
SELECT * FROM SessionData WHERE userId = ?;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Migrate DB from version 9
|
||||||
|
-- Add position to be able to sort account by session creation date
|
||||||
|
-- Add lastUsageIndex so we can restore the last session and switch to another one
|
||||||
|
-- Add display name and avatar url of the user so that we can display a list of accounts.
|
||||||
|
|
||||||
|
ALTER TABLE SessionData ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE SessionData ADD COLUMN lastUsageIndex INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE SessionData ADD COLUMN userDisplayName TEXT;
|
||||||
|
ALTER TABLE SessionData ADD COLUMN userAvatarUrl TEXT;
|
||||||
|
|
@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.matrix.session.SessionData
|
import io.element.android.libraries.matrix.session.SessionData
|
||||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||||
|
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -45,11 +46,11 @@ class DatabaseSessionStoreTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `addSession persists the SessionData into the DB`() = runTest {
|
fun `addSession persists the SessionData into the DB`() = runTest {
|
||||||
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isNull()
|
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isNull()
|
||||||
|
|
||||||
databaseSessionStore.addSession(aSessionData.toApiModel())
|
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||||
|
|
||||||
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData)
|
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData)
|
||||||
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
|
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +60,12 @@ class DatabaseSessionStoreTest {
|
||||||
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
|
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
databaseSessionStore.addSession(aSessionData.toApiModel())
|
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||||
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
|
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
|
||||||
// TODO add more sessions in multi-account PR.
|
// Add a second session
|
||||||
|
databaseSessionStore.addSession(aSessionData.copy(userId = "otherUserId").toApiModel())
|
||||||
|
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = "otherUserId", isTokenValid = true))
|
||||||
|
// Remove the second session
|
||||||
|
databaseSessionStore.removeSession("otherUserId")
|
||||||
|
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
|
||||||
// Remove the first session
|
// Remove the first session
|
||||||
databaseSessionStore.removeSession(aSessionData.userId)
|
databaseSessionStore.removeSession(aSessionData.userId)
|
||||||
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
|
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
|
|
@ -124,7 +130,83 @@ class DatabaseSessionStoreTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `update session update all fields except loginTimestamp`() = runTest {
|
fun `updateUserProfile does nothing if the session is not found`() = runTest {
|
||||||
|
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
|
||||||
|
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateUserProfile update the data`() = runTest {
|
||||||
|
database.sessionDataQueries.insertSessionData(aSessionData)
|
||||||
|
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
|
||||||
|
val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
|
||||||
|
assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName")
|
||||||
|
assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setLatestSession is no op when the session is already the latest session`() = runTest {
|
||||||
|
database.sessionDataQueries.insertSessionData(aSessionData)
|
||||||
|
val session = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
|
||||||
|
assertThat(session.lastUsageIndex).isEqualTo(0)
|
||||||
|
assertThat(session.position).isEqualTo(0)
|
||||||
|
databaseSessionStore.setLatestSession(aSessionData.userId)
|
||||||
|
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne().lastUsageIndex).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setLatestSession is no op when the session is not found`() = runTest {
|
||||||
|
databaseSessionStore.setLatestSession(aSessionData.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi session test`() = runTest {
|
||||||
|
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||||
|
val session = databaseSessionStore.getSession(aSessionData.userId)!!
|
||||||
|
assertThat(session.lastUsageIndex).isEqualTo(0)
|
||||||
|
assertThat(session.position).isEqualTo(0)
|
||||||
|
val secondSessionData = aSessionData.copy(
|
||||||
|
userId = "otherUserId",
|
||||||
|
position = 1,
|
||||||
|
lastUsageIndex = 1,
|
||||||
|
)
|
||||||
|
databaseSessionStore.addSession(secondSessionData.toApiModel())
|
||||||
|
val secondSession = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
|
||||||
|
assertThat(secondSession.lastUsageIndex).isEqualTo(1)
|
||||||
|
assertThat(secondSession.position).isEqualTo(1)
|
||||||
|
// Set the first session as the latest
|
||||||
|
databaseSessionStore.setLatestSession(aSessionData.userId)
|
||||||
|
val firstSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
|
||||||
|
assertThat(firstSession.lastUsageIndex).isEqualTo(2)
|
||||||
|
assertThat(firstSession.position).isEqualTo(0)
|
||||||
|
// Check that the second session has not been altered
|
||||||
|
val secondSession2 = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
|
||||||
|
assertThat(secondSession2.lastUsageIndex).isEqualTo(1)
|
||||||
|
assertThat(secondSession2.position).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test sessionsFlow()`() = runTest {
|
||||||
|
databaseSessionStore.sessionsFlow().test {
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
databaseSessionStore.addSession(aSessionData.toApiModel())
|
||||||
|
assertThat(awaitItem().size).isEqualTo(1)
|
||||||
|
val secondSessionData = aSessionData.copy(
|
||||||
|
userId = "otherUserId",
|
||||||
|
position = 1,
|
||||||
|
lastUsageIndex = 1,
|
||||||
|
)
|
||||||
|
databaseSessionStore.addSession(secondSessionData.toApiModel())
|
||||||
|
assertThat(awaitItem().size).isEqualTo(2)
|
||||||
|
databaseSessionStore.removeSession(aSessionData.userId)
|
||||||
|
assertThat(awaitItem().size).isEqualTo(1)
|
||||||
|
databaseSessionStore.removeSession(secondSessionData.userId)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `update session update all fields except info used by the application`() = runTest {
|
||||||
val firstSessionData = SessionData(
|
val firstSessionData = SessionData(
|
||||||
userId = "userId",
|
userId = "userId",
|
||||||
deviceId = "deviceId",
|
deviceId = "deviceId",
|
||||||
|
|
@ -139,6 +221,10 @@ class DatabaseSessionStoreTest {
|
||||||
passphrase = "aPassphrase",
|
passphrase = "aPassphrase",
|
||||||
sessionPath = "sessionPath",
|
sessionPath = "sessionPath",
|
||||||
cachePath = "cachePath",
|
cachePath = "cachePath",
|
||||||
|
position = 0,
|
||||||
|
lastUsageIndex = 0,
|
||||||
|
userDisplayName = "userDisplayName",
|
||||||
|
userAvatarUrl = "userAvatarUrl",
|
||||||
)
|
)
|
||||||
val secondSessionData = SessionData(
|
val secondSessionData = SessionData(
|
||||||
userId = "userId",
|
userId = "userId",
|
||||||
|
|
@ -152,8 +238,12 @@ class DatabaseSessionStoreTest {
|
||||||
isTokenValid = 1,
|
isTokenValid = 1,
|
||||||
loginType = null,
|
loginType = null,
|
||||||
passphrase = "aPassphraseAltered",
|
passphrase = "aPassphraseAltered",
|
||||||
sessionPath = "sessionPath",
|
sessionPath = "sessionPathAltered",
|
||||||
cachePath = "cachePath",
|
cachePath = "cachePathAltered",
|
||||||
|
position = 1,
|
||||||
|
lastUsageIndex = 1,
|
||||||
|
userDisplayName = "userDisplayNameAltered",
|
||||||
|
userAvatarUrl = "userAvatarUrlAltered",
|
||||||
)
|
)
|
||||||
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
|
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
|
||||||
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
|
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
|
||||||
|
|
@ -174,6 +264,11 @@ class DatabaseSessionStoreTest {
|
||||||
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
|
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
|
||||||
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
|
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
|
||||||
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
|
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
|
||||||
|
// Check that application data have not been altered
|
||||||
|
assertThat(alteredSession.position).isEqualTo(firstSessionData.position)
|
||||||
|
assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex)
|
||||||
|
assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName)
|
||||||
|
assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -188,10 +283,14 @@ class DatabaseSessionStoreTest {
|
||||||
loginTimestamp = 1,
|
loginTimestamp = 1,
|
||||||
oidcData = "aOidcData",
|
oidcData = "aOidcData",
|
||||||
isTokenValid = 1,
|
isTokenValid = 1,
|
||||||
loginType = null,
|
loginType = LoginType.PASSWORD.name,
|
||||||
passphrase = "aPassphrase",
|
passphrase = "aPassphrase",
|
||||||
sessionPath = "sessionPath",
|
sessionPath = "sessionPath",
|
||||||
cachePath = "cachePath",
|
cachePath = "cachePath",
|
||||||
|
position = 0,
|
||||||
|
lastUsageIndex = 0,
|
||||||
|
userDisplayName = "userDisplayName",
|
||||||
|
userAvatarUrl = "userAvatarUrl",
|
||||||
)
|
)
|
||||||
val secondSessionData = SessionData(
|
val secondSessionData = SessionData(
|
||||||
userId = "userIdUnknown",
|
userId = "userIdUnknown",
|
||||||
|
|
@ -203,10 +302,14 @@ class DatabaseSessionStoreTest {
|
||||||
loginTimestamp = 2,
|
loginTimestamp = 2,
|
||||||
oidcData = "aOidcDataAltered",
|
oidcData = "aOidcDataAltered",
|
||||||
isTokenValid = 1,
|
isTokenValid = 1,
|
||||||
loginType = null,
|
loginType = LoginType.PASSWORD.name,
|
||||||
passphrase = "aPassphraseAltered",
|
passphrase = "aPassphraseAltered",
|
||||||
sessionPath = "sessionPath",
|
sessionPath = "sessionPathAltered",
|
||||||
cachePath = "cachePath",
|
cachePath = "cachePathAltered",
|
||||||
|
position = 1,
|
||||||
|
lastUsageIndex = 1,
|
||||||
|
userDisplayName = "userDisplayNameAltered",
|
||||||
|
userAvatarUrl = "userAvatarUrlAltered",
|
||||||
)
|
)
|
||||||
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
|
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
|
||||||
|
|
||||||
|
|
@ -216,14 +319,6 @@ class DatabaseSessionStoreTest {
|
||||||
// Get the session and check that it has not been altered
|
// Get the session and check that it has not been altered
|
||||||
val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel()
|
val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel()
|
||||||
|
|
||||||
assertThat(notAlteredSession.userId).isEqualTo(firstSessionData.userId)
|
assertThat(notAlteredSession).isEqualTo(firstSessionData)
|
||||||
assertThat(notAlteredSession.deviceId).isEqualTo(firstSessionData.deviceId)
|
|
||||||
assertThat(notAlteredSession.accessToken).isEqualTo(firstSessionData.accessToken)
|
|
||||||
assertThat(notAlteredSession.refreshToken).isEqualTo(firstSessionData.refreshToken)
|
|
||||||
assertThat(notAlteredSession.homeserverUrl).isEqualTo(firstSessionData.homeserverUrl)
|
|
||||||
assertThat(notAlteredSession.slidingSyncProxy).isEqualTo(firstSessionData.slidingSyncProxy)
|
|
||||||
assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
|
|
||||||
assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData)
|
|
||||||
assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,8 @@ internal fun aSessionData() = SessionData(
|
||||||
passphrase = null,
|
passphrase = null,
|
||||||
sessionPath = "sessionPath",
|
sessionPath = "sessionPath",
|
||||||
cachePath = "cachePath",
|
cachePath = "cachePath",
|
||||||
|
position = 0,
|
||||||
|
lastUsageIndex = 0,
|
||||||
|
userDisplayName = null,
|
||||||
|
userAvatarUrl = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
class InMemorySessionStore(
|
class InMemorySessionStore(
|
||||||
initialList: List<SessionData> = emptyList(),
|
initialList: List<SessionData> = emptyList(),
|
||||||
|
private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") },
|
||||||
|
private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") },
|
||||||
) : SessionStore {
|
) : SessionStore {
|
||||||
private val sessionDataListFlow = MutableStateFlow(initialList)
|
private val sessionDataListFlow = MutableStateFlow(initialList)
|
||||||
|
|
||||||
|
|
@ -53,6 +55,10 @@ class InMemorySessionStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
|
||||||
|
updateUserProfileResult(sessionId, displayName, avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getSession(sessionId: String): SessionData? {
|
override suspend fun getSession(sessionId: String): SessionData? {
|
||||||
return sessionDataListFlow.value.firstOrNull { it.userId == sessionId }
|
return sessionDataListFlow.value.firstOrNull { it.userId == sessionId }
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +71,10 @@ class InMemorySessionStore(
|
||||||
return sessionDataListFlow.value.firstOrNull()
|
return sessionDataListFlow.value.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setLatestSession(sessionId: String) {
|
||||||
|
setLatestSessionResult(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun removeSession(sessionId: String) {
|
override suspend fun removeSession(sessionId: String) {
|
||||||
val currentList = sessionDataListFlow.value.toMutableList()
|
val currentList = sessionDataListFlow.value.toMutableList()
|
||||||
currentList.removeAll { it.userId == sessionId }
|
currentList.removeAll { it.userId == sessionId }
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ fun aSessionData(
|
||||||
cachePath: String = "/a/path/to/a/cache",
|
cachePath: String = "/a/path/to/a/cache",
|
||||||
accessToken: String = "anAccessToken",
|
accessToken: String = "anAccessToken",
|
||||||
refreshToken: String? = "aRefreshToken",
|
refreshToken: String? = "aRefreshToken",
|
||||||
): SessionData {
|
position: Long = 0,
|
||||||
|
lastUsageIndex: Long = 0,
|
||||||
|
userDisplayName: String? = null,
|
||||||
|
userAvatarUrl: String? = null,
|
||||||
|
): SessionData {
|
||||||
return SessionData(
|
return SessionData(
|
||||||
userId = sessionId,
|
userId = sessionId,
|
||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
|
|
@ -33,5 +37,9 @@ fun aSessionData(
|
||||||
passphrase = null,
|
passphrase = null,
|
||||||
sessionPath = sessionPath,
|
sessionPath = sessionPath,
|
||||||
cachePath = cachePath,
|
cachePath = cachePath,
|
||||||
|
position = position,
|
||||||
|
lastUsageIndex = lastUsageIndex,
|
||||||
|
userDisplayName = userDisplayName,
|
||||||
|
userAvatarUrl = userAvatarUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
|
|
||||||
interface PushHistoryEntryPoint : FeatureEntryPoint {
|
interface PushHistoryEntryPoint : FeatureEntryPoint {
|
||||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||||
|
|
@ -25,6 +24,6 @@ interface PushHistoryEntryPoint : FeatureEntryPoint {
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onDone()
|
fun onDone()
|
||||||
fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId)
|
fun navigateTo(roomId: RoomId, eventId: EventId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,13 @@
|
||||||
|
|
||||||
package io.element.android.libraries.troubleshoot.impl.history
|
package io.element.android.libraries.troubleshoot.impl.history
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
sealed interface PushHistoryEvents {
|
sealed interface PushHistoryEvents {
|
||||||
data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents
|
data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents
|
||||||
data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents
|
data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents
|
||||||
|
data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId) : PushHistoryEvents
|
||||||
data object ClearDialog : PushHistoryEvents
|
data object ClearDialog : PushHistoryEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import io.element.android.annotations.ContributesNode
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
||||||
import io.element.android.services.analytics.api.ScreenTracker
|
import io.element.android.services.analytics.api.ScreenTracker
|
||||||
|
|
||||||
|
|
@ -29,21 +28,23 @@ import io.element.android.services.analytics.api.ScreenTracker
|
||||||
class PushHistoryNode(
|
class PushHistoryNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: PushHistoryPresenter,
|
presenterFactory: PushHistoryPresenter.Factory,
|
||||||
private val screenTracker: ScreenTracker,
|
private val screenTracker: ScreenTracker,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins), PushHistoryNavigator {
|
||||||
private fun onDone() {
|
private fun onDone() {
|
||||||
plugins<PushHistoryEntryPoint.Callback>().forEach {
|
plugins<PushHistoryEntryPoint.Callback>().forEach {
|
||||||
it.onDone()
|
it.onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
|
override fun navigateTo(roomId: RoomId, eventId: EventId) {
|
||||||
plugins<PushHistoryEntryPoint.Callback>().forEach {
|
plugins<PushHistoryEntryPoint.Callback>().forEach {
|
||||||
it.onItemClick(sessionId, roomId, eventId)
|
it.navigateTo(roomId, eventId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(this)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot)
|
screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot)
|
||||||
|
|
@ -51,7 +52,6 @@ class PushHistoryNode(
|
||||||
PushHistoryView(
|
PushHistoryView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = ::onDone,
|
onBackClick = ::onDone,
|
||||||
onItemClick = ::onItemClick,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,36 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Inject
|
fun interface PushHistoryNavigator {
|
||||||
|
fun navigateTo(roomId: RoomId, eventId: EventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedInject
|
||||||
class PushHistoryPresenter(
|
class PushHistoryPresenter(
|
||||||
|
@Assisted private val pushHistoryNavigator: PushHistoryNavigator,
|
||||||
private val pushService: PushService,
|
private val pushService: PushService,
|
||||||
|
matrixClient: MatrixClient,
|
||||||
) : Presenter<PushHistoryState> {
|
) : Presenter<PushHistoryState> {
|
||||||
|
@AssistedFactory
|
||||||
|
fun interface Factory {
|
||||||
|
fun create(pushHistoryNavigator: PushHistoryNavigator): PushHistoryPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sessionId = matrixClient.sessionId
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): PushHistoryState {
|
override fun present(): PushHistoryState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -41,6 +59,7 @@ class PushHistoryPresenter(
|
||||||
}
|
}
|
||||||
}.collectAsState(emptyList())
|
}.collectAsState(emptyList())
|
||||||
var resetAction: AsyncAction<Unit> by remember { mutableStateOf(AsyncAction.Uninitialized) }
|
var resetAction: AsyncAction<Unit> by remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
|
var showNotSameAccountError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun handleEvents(event: PushHistoryEvents) {
|
fun handleEvents(event: PushHistoryEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
@ -60,6 +79,14 @@ class PushHistoryPresenter(
|
||||||
}
|
}
|
||||||
PushHistoryEvents.ClearDialog -> {
|
PushHistoryEvents.ClearDialog -> {
|
||||||
resetAction = AsyncAction.Uninitialized
|
resetAction = AsyncAction.Uninitialized
|
||||||
|
showNotSameAccountError = false
|
||||||
|
}
|
||||||
|
is PushHistoryEvents.NavigateTo -> {
|
||||||
|
if (event.sessionId != sessionId) {
|
||||||
|
showNotSameAccountError = true
|
||||||
|
} else {
|
||||||
|
pushHistoryNavigator.navigateTo(event.roomId, event.eventId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +96,7 @@ class PushHistoryPresenter(
|
||||||
pushHistoryItems = pushHistory.toImmutableList(),
|
pushHistoryItems = pushHistory.toImmutableList(),
|
||||||
showOnlyErrors = showOnlyErrors,
|
showOnlyErrors = showOnlyErrors,
|
||||||
resetAction = resetAction,
|
resetAction = resetAction,
|
||||||
|
showNotSameAccountError = showNotSameAccountError,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,6 @@ data class PushHistoryState(
|
||||||
val pushHistoryItems: ImmutableList<PushHistoryItem>,
|
val pushHistoryItems: ImmutableList<PushHistoryItem>,
|
||||||
val showOnlyErrors: Boolean,
|
val showOnlyErrors: Boolean,
|
||||||
val resetAction: AsyncAction<Unit>,
|
val resetAction: AsyncAction<Unit>,
|
||||||
|
val showNotSameAccountError: Boolean,
|
||||||
val eventSink: (PushHistoryEvents) -> Unit,
|
val eventSink: (PushHistoryEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ open class PushHistoryStateProvider : PreviewParameterProvider<PushHistoryState>
|
||||||
aPushHistoryState(
|
aPushHistoryState(
|
||||||
resetAction = AsyncAction.ConfirmingNoParams,
|
resetAction = AsyncAction.ConfirmingNoParams,
|
||||||
),
|
),
|
||||||
|
aPushHistoryState(
|
||||||
|
showNotSameAccountError = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,12 +51,14 @@ fun aPushHistoryState(
|
||||||
pushHistoryItems: List<PushHistoryItem> = emptyList(),
|
pushHistoryItems: List<PushHistoryItem> = emptyList(),
|
||||||
showOnlyErrors: Boolean = false,
|
showOnlyErrors: Boolean = false,
|
||||||
resetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
resetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
showNotSameAccountError: Boolean = false,
|
||||||
eventSink: (PushHistoryEvents) -> Unit = {},
|
eventSink: (PushHistoryEvents) -> Unit = {},
|
||||||
) = PushHistoryState(
|
) = PushHistoryState(
|
||||||
pushCounter = pushCounter,
|
pushCounter = pushCounter,
|
||||||
pushHistoryItems = pushHistoryItems.toImmutableList(),
|
pushHistoryItems = pushHistoryItems.toImmutableList(),
|
||||||
showOnlyErrors = showOnlyErrors,
|
showOnlyErrors = showOnlyErrors,
|
||||||
resetAction = resetAction,
|
resetAction = resetAction,
|
||||||
|
showNotSameAccountError = showNotSameAccountError,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -48,9 +49,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.push.api.history.PushHistoryItem
|
import io.element.android.libraries.push.api.history.PushHistoryItem
|
||||||
import io.element.android.libraries.troubleshoot.impl.R
|
import io.element.android.libraries.troubleshoot.impl.R
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -60,7 +58,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
fun PushHistoryView(
|
fun PushHistoryView(
|
||||||
state: PushHistoryState,
|
state: PushHistoryState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onItemClick: (SessionId, RoomId, EventId) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
@ -123,7 +120,6 @@ fun PushHistoryView(
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.consumeWindowInsets(padding),
|
.consumeWindowInsets(padding),
|
||||||
state = state,
|
state = state,
|
||||||
onItemClick = onItemClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,12 +138,18 @@ fun PushHistoryView(
|
||||||
},
|
},
|
||||||
onErrorDismiss = {},
|
onErrorDismiss = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (state.showNotSameAccountError) {
|
||||||
|
ErrorDialog(
|
||||||
|
content = "Please switch account first to navigate to the event.",
|
||||||
|
onSubmit = { state.eventSink(PushHistoryEvents.ClearDialog) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PushHistoryContent(
|
private fun PushHistoryContent(
|
||||||
state: PushHistoryState,
|
state: PushHistoryState,
|
||||||
onItemClick: (SessionId, RoomId, EventId) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -173,7 +175,7 @@ private fun PushHistoryContent(
|
||||||
val roomId = pushHistory.roomId
|
val roomId = pushHistory.roomId
|
||||||
val eventId = pushHistory.eventId
|
val eventId = pushHistory.eventId
|
||||||
if (sessionId != null && roomId != null && eventId != null) {
|
if (sessionId != null && roomId != null && eventId != null) {
|
||||||
onItemClick(sessionId, roomId, eventId)
|
state.eventSink(PushHistoryEvents.NavigateTo(sessionId, roomId, eventId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -271,6 +273,5 @@ internal fun PushHistoryViewPreview(
|
||||||
PushHistoryView(
|
PushHistoryView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
onItemClick = { _, _, _ -> },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.push.test.FakePushService
|
import io.element.android.libraries.push.test.FakePushService
|
||||||
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
||||||
import io.element.android.services.analytics.test.FakeScreenTracker
|
import io.element.android.services.analytics.test.FakeScreenTracker
|
||||||
|
|
@ -32,15 +32,21 @@ class DefaultPushHistoryEntryPointTest {
|
||||||
PushHistoryNode(
|
PushHistoryNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
presenter = PushHistoryPresenter(
|
presenterFactory = {
|
||||||
pushService = FakePushService(),
|
PushHistoryPresenter(
|
||||||
),
|
pushHistoryNavigator = object : PushHistoryNavigator {
|
||||||
|
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
|
||||||
|
},
|
||||||
|
pushService = FakePushService(),
|
||||||
|
matrixClient = FakeMatrixClient(),
|
||||||
|
)
|
||||||
|
},
|
||||||
screenTracker = FakeScreenTracker(),
|
screenTracker = FakeScreenTracker(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : PushHistoryEntryPoint.Callback {
|
val callback = object : PushHistoryEntryPoint.Callback {
|
||||||
override fun onDone() = lambdaError()
|
override fun onDone() = lambdaError()
|
||||||
override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) = lambdaError()
|
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
|
||||||
}
|
}
|
||||||
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
|
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
|
||||||
.callback(callback)
|
.callback(callback)
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,19 @@ package io.element.android.libraries.troubleshoot.impl.history
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
import io.element.android.libraries.push.test.FakePushService
|
import io.element.android.libraries.push.test.FakePushService
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -29,6 +39,7 @@ class PushHistoryPresenterTest {
|
||||||
assertThat(initialState.pushHistoryItems).isEmpty()
|
assertThat(initialState.pushHistoryItems).isEmpty()
|
||||||
assertThat(initialState.showOnlyErrors).isFalse()
|
assertThat(initialState.showOnlyErrors).isFalse()
|
||||||
assertThat(initialState.resetAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(initialState.resetAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
assertThat(initialState.showNotSameAccountError).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,11 +130,57 @@ class PushHistoryPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - item click current account`() = runTest {
|
||||||
|
val pushHistoryNavigatorResult = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
|
||||||
|
val presenter = createPushHistoryPresenter(
|
||||||
|
pushHistoryNavigator = { roomId, eventId ->
|
||||||
|
pushHistoryNavigatorResult(roomId, eventId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(
|
||||||
|
PushHistoryEvents.NavigateTo(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pushHistoryNavigatorResult.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(A_ROOM_ID), value(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - item click not current account`() = runTest {
|
||||||
|
val presenter = createPushHistoryPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(
|
||||||
|
PushHistoryEvents.NavigateTo(
|
||||||
|
sessionId = A_SESSION_ID_2,
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(awaitItem().showNotSameAccountError).isTrue()
|
||||||
|
// Reset error
|
||||||
|
initialState.eventSink(PushHistoryEvents.ClearDialog)
|
||||||
|
assertThat(awaitItem().showNotSameAccountError).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createPushHistoryPresenter(
|
private fun createPushHistoryPresenter(
|
||||||
|
pushHistoryNavigator: PushHistoryNavigator = PushHistoryNavigator { _, _ -> lambdaError() },
|
||||||
pushService: PushService = FakePushService(),
|
pushService: PushService = FakePushService(),
|
||||||
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
): PushHistoryPresenter {
|
): PushHistoryPresenter {
|
||||||
return PushHistoryPresenter(
|
return PushHistoryPresenter(
|
||||||
|
pushHistoryNavigator = pushHistoryNavigator,
|
||||||
pushService = pushService,
|
pushService = pushService,
|
||||||
|
matrixClient = matrixClient,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,14 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_FORMATTED_DATE
|
import io.element.android.libraries.matrix.test.A_FORMATTED_DATE
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParams
|
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
|
||||||
import io.element.android.tests.testutils.lambda.value
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
|
|
@ -103,9 +97,8 @@ class PushHistoryViewTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on a valid event invokes the expected callback`() {
|
fun `clicking on a valid event emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||||
val onItemClick = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
|
||||||
rule.setPushHistoryView(
|
rule.setPushHistoryView(
|
||||||
aPushHistoryState(
|
aPushHistoryState(
|
||||||
pushHistoryItems = listOf(
|
pushHistoryItems = listOf(
|
||||||
|
|
@ -118,25 +111,26 @@ class PushHistoryViewTest {
|
||||||
),
|
),
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
onItemClick = onItemClick,
|
|
||||||
)
|
)
|
||||||
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
|
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
|
||||||
onItemClick.assertions()
|
eventsRecorder.assertSingle(
|
||||||
.isCalledOnce()
|
PushHistoryEvents.NavigateTo(
|
||||||
.with(value(A_SESSION_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
|
sessionId = A_SESSION_ID,
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPushHistoryView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPushHistoryView(
|
||||||
state: PushHistoryState,
|
state: PushHistoryState,
|
||||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onItemClick: (SessionId, RoomId, EventId) -> Unit = EnsureNeverCalledWithThreeParams(),
|
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
PushHistoryView(
|
PushHistoryView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
onItemClick = onItemClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
implementation(project(":libraries:mediaupload:impl"))
|
implementation(project(":libraries:mediaupload:impl"))
|
||||||
implementation(project(":libraries:usersearch:impl"))
|
implementation(project(":libraries:usersearch:impl"))
|
||||||
implementation(project(":libraries:textcomposer:impl"))
|
implementation(project(":libraries:textcomposer:impl"))
|
||||||
|
implementation(project(":libraries:accountselect:impl"))
|
||||||
implementation(project(":libraries:roomselect:impl"))
|
implementation(project(":libraries:roomselect:impl"))
|
||||||
implementation(project(":libraries:cryptography:impl"))
|
implementation(project(":libraries:cryptography:impl"))
|
||||||
implementation(project(":libraries:voiceplayer:impl"))
|
implementation(project(":libraries:voiceplayer:impl"))
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
|
||||||
import im.vector.app.features.analytics.plan.SuperProperties
|
import im.vector.app.features.analytics.plan.SuperProperties
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
import im.vector.app.features.analytics.plan.UserProperties
|
||||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -40,6 +41,7 @@ class DefaultAnalyticsService(
|
||||||
@AppCoroutineScope
|
@AppCoroutineScope
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val sessionObserver: SessionObserver,
|
private val sessionObserver: SessionObserver,
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
) : AnalyticsService, SessionListener {
|
) : AnalyticsService, SessionListener {
|
||||||
// Cache for the store values
|
// Cache for the store values
|
||||||
private val userConsent = AtomicBoolean(false)
|
private val userConsent = AtomicBoolean(false)
|
||||||
|
|
@ -80,8 +82,10 @@ class DefaultAnalyticsService(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun onSessionDeleted(userId: String) {
|
override suspend fun onSessionDeleted(userId: String) {
|
||||||
// Delete the store
|
// Delete the store when the last session is deleted
|
||||||
analyticsStore.reset()
|
if (sessionStore.getAllSessions().isEmpty()) {
|
||||||
|
analyticsStore.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeUserConsent() {
|
private fun observeUserConsent() {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import im.vector.app.features.analytics.plan.PollEnd
|
import im.vector.app.features.analytics.plan.PollEnd
|
||||||
import im.vector.app.features.analytics.plan.SuperProperties
|
import im.vector.app.features.analytics.plan.SuperProperties
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
import im.vector.app.features.analytics.plan.UserProperties
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||||
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
|
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
|
||||||
import io.element.android.services.analytics.impl.store.AnalyticsStore
|
import io.element.android.services.analytics.impl.store.AnalyticsStore
|
||||||
import io.element.android.services.analytics.impl.store.FakeAnalyticsStore
|
import io.element.android.services.analytics.impl.store.FakeAnalyticsStore
|
||||||
|
|
@ -167,7 +169,7 @@ class DefaultAnalyticsServiceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when a session is deleted, the store is reset`() = runTest {
|
fun `when the last session is deleted, the store is reset`() = runTest {
|
||||||
val resetLambda = lambdaRecorder<Unit> { }
|
val resetLambda = lambdaRecorder<Unit> { }
|
||||||
val store = FakeAnalyticsStore(
|
val store = FakeAnalyticsStore(
|
||||||
resetLambda = resetLambda,
|
resetLambda = resetLambda,
|
||||||
|
|
@ -258,11 +260,13 @@ class DefaultAnalyticsServiceTest {
|
||||||
),
|
),
|
||||||
analyticsStore: AnalyticsStore = FakeAnalyticsStore(),
|
analyticsStore: AnalyticsStore = FakeAnalyticsStore(),
|
||||||
sessionObserver: SessionObserver = NoOpSessionObserver(),
|
sessionObserver: SessionObserver = NoOpSessionObserver(),
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
) = DefaultAnalyticsService(
|
) = DefaultAnalyticsService(
|
||||||
analyticsProviders = analyticsProviders,
|
analyticsProviders = analyticsProviders,
|
||||||
analyticsStore = analyticsStore,
|
analyticsStore = analyticsStore,
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
sessionObserver = sessionObserver,
|
sessionObserver = sessionObserver,
|
||||||
|
sessionStore = sessionStore,
|
||||||
).also {
|
).also {
|
||||||
// Wait for the service to be ready
|
// Wait for the service to be ready
|
||||||
delay(1)
|
delay(1)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class KonsistClassNameTest {
|
||||||
.withAllParentsOf(PreviewParameterProvider::class)
|
.withAllParentsOf(PreviewParameterProvider::class)
|
||||||
.withoutName(
|
.withoutName(
|
||||||
"AspectRatioProvider",
|
"AspectRatioProvider",
|
||||||
|
"LoginModeViewErrorProvider",
|
||||||
"OverlapRatioProvider",
|
"OverlapRatioProvider",
|
||||||
"TextFileContentProvider",
|
"TextFileContentProvider",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ class KonsistPreviewTest {
|
||||||
"BackgroundVerticalGradientEnterprisePreview",
|
"BackgroundVerticalGradientEnterprisePreview",
|
||||||
"BackgroundVerticalGradientPreview",
|
"BackgroundVerticalGradientPreview",
|
||||||
"ColorAliasesPreview",
|
"ColorAliasesPreview",
|
||||||
|
"DefaultRoomListTopBarMultiAccountPreview",
|
||||||
"DefaultRoomListTopBarWithIndicatorPreview",
|
"DefaultRoomListTopBarWithIndicatorPreview",
|
||||||
"FocusedEventEnterprisePreview",
|
"FocusedEventEnterprisePreview",
|
||||||
"FocusedEventPreview",
|
"FocusedEventPreview",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b289624c8461e08c945a254eb629ea536552e2652c7182be21a7bd9f183da022
|
||||||
|
size 26185
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b86aaf39367bd454a913c4ed6cd17bbaff52e54349e0e6d363d6f84ccdc43c5f
|
||||||
|
size 23678
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4eaf3d650155e9a779cf796cef116eaba0f1d6722b229332b224475393a88178
|
||||||
|
size 16828
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:59f062f54833df71be9d7c4e785bb01013a10642e0d863bf7ef2abd8862b93c8
|
||||||
|
size 15476
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue