Merge branch 'develop' into feature/fga/space_list_join_action

This commit is contained in:
ganfra 2025-09-29 18:01:42 +02:00
commit 6eae9e379f
591 changed files with 6155 additions and 2140 deletions

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.2.10" />
<option name="version" value="2.2.20" />
</component>
</project>

View file

@ -8,6 +8,6 @@ appId: ${MAESTRO_APP_ID}
- hideKeyboard
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Verification complete"
visible: "Device verified"
timeout: 30000
- tapOn: "Continue"

View file

@ -33,7 +33,6 @@ import io.element.android.x.BuildConfig
import io.element.android.x.R
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.plus
import java.io.File
@ -107,11 +106,7 @@ object AppModule {
@Provides
@SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers {
return CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
)
return CoroutineDispatchers.Default
}
@Provides

View file

@ -26,9 +26,11 @@ dependencies {
allFeaturesApi(project)
implementation(projects.libraries.core)
implementation(projects.libraries.accountselect.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.oidc.api)
implementation(projects.libraries.preferences.api)

View file

@ -24,7 +24,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.libraries.architecture.NodeInputs
@ -41,7 +41,7 @@ import kotlinx.parcelize.Parcelize
* This allow to inject objects with SessionScope in the constructor of [LoggedInFlowNode].
*/
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class LoggedInAppScopeFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ -57,6 +57,7 @@ class LoggedInAppScopeFlowNode(
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onOpenBugReport()
fun onAddAccount()
}
@Parcelize
@ -83,6 +84,10 @@ class LoggedInAppScopeFlowNode(
override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}

View file

@ -31,9 +31,17 @@ class LoggedInEventProcessor(
observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach {
when (it.change) {
MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room)
.onEach { roomMemberShipUpdate ->
when (roomMemberShipUpdate.change) {
MembershipChange.LEFT -> {
displayMessage(
if (roomMemberShipUpdate.isSpace) {
CommonStrings.common_current_user_left_space
} else {
CommonStrings.common_current_user_left_room
}
)
}
MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite)
MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock)
else -> Unit

View file

@ -38,6 +38,7 @@ import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
@ -75,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.RoomId
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.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -100,7 +100,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class LoggedInFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ -138,6 +138,7 @@ class LoggedInFlowNode(
) {
interface Callback : Plugin {
fun onOpenBugReport()
fun onAddAccount()
}
private val loggedInFlowProcessor = LoggedInEventProcessor(
@ -392,6 +393,10 @@ class LoggedInFlowNode(
}
is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback {
override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
}
override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
@ -404,11 +409,7 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
}
override fun navigateTo(sessionId: SessionId, 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")
}
override fun navigateTo(roomId: RoomId, eventId: EventId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId)))
}
}

View file

@ -22,7 +22,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
@ -36,7 +36,7 @@ import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactor
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class NotLoggedInFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -9,6 +9,8 @@ package io.element.android.appnav
import android.content.Intent
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.fillMaxSize
import androidx.compose.runtime.Composable
@ -23,9 +25,11 @@ import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.MatrixSessionCache
@ -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.reporter.BugReporter
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.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData
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.toRoomIdOrAlias
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.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class)
@Inject
class RootFlowNode(
@ContributesNode(AppScope::class) @AssistedInject class RootFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val sessionStore: SessionStore,
@ -71,9 +78,11 @@ class RootFlowNode(
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint,
private val accountSelectEntryPoint: AccountSelectEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val bugReporter: BugReporter,
private val featureFlagService: FeatureFlagService,
) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SplashScreen,
@ -95,27 +104,24 @@ class RootFlowNode(
}
private fun observeNavState() {
navStateFlowFactory.create(buildContext.savedStateMap)
.distinctUntilChanged()
.onEach { navState ->
Timber.v("navState=$navState")
when (navState.loggedInState) {
is LoggedInState.LoggedIn -> {
if (navState.loggedInState.isTokenValid) {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow(null) }
)
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
navStateFlowFactory.create(buildContext.savedStateMap).distinctUntilChanged().onEach { navState ->
Timber.v("navState=$navState")
when (navState.loggedInState) {
is LoggedInState.LoggedIn -> {
if (navState.loggedInState.isTokenValid) {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow(null) }
)
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
}
}
.launchIn(lifecycleScope)
}.launchIn(lifecycleScope)
}
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
@ -137,20 +143,17 @@ class RootFlowNode(
onFailure: () -> Unit,
onSuccess: (SessionId) -> Unit,
) {
matrixSessionCache.getOrRestore(sessionId)
.onSuccess {
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
}
.onFailure {
Timber.e(it, "Failed to restore session $sessionId")
onFailure()
}
matrixSessionCache.getOrRestore(sessionId).onSuccess {
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
}.onFailure {
Timber.e(it, "Failed to restore session $sessionId")
onFailure()
}
}
private suspend fun tryToRestoreLatestSession(
onSuccess: (SessionId) -> Unit,
onFailure: () -> Unit
onSuccess: (SessionId) -> Unit, onFailure: () -> Unit
) {
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
@ -172,32 +175,45 @@ class RootFlowNode(
modifier = modifier,
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 {
@Parcelize
data object SplashScreen : NavTarget
@Parcelize data object SplashScreen : NavTarget
@Parcelize
data class NotLoggedInFlow(
@Parcelize data class AccountSelect(
val currentSessionId: SessionId,
val intent: Intent?,
val permalinkData: PermalinkData?,
) : NavTarget
@Parcelize data class NotLoggedInFlow(
val params: LoginParams?
) : NavTarget
@Parcelize
data class LoggedInFlow(
val sessionId: SessionId,
val navId: Int
@Parcelize data class LoggedInFlow(
val sessionId: SessionId, val navId: Int
) : NavTarget
@Parcelize
data class SignedOutFlow(
@Parcelize data class SignedOutFlow(
val sessionId: SessionId
) : NavTarget
@Parcelize
data object BugReport : NavTarget
@Parcelize data object BugReport : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -211,6 +227,10 @@ class RootFlowNode(
override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}
override fun onAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
}
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
}
@ -226,13 +246,11 @@ class RootFlowNode(
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
}
is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext)
.params(
SignedOutEntryPoint.Params(
sessionId = navTarget.sessionId
)
signedOutEntryPoint.nodeBuilder(this, buildContext).params(
SignedOutEntryPoint.Params(
sessionId = navTarget.sessionId
)
.build()
).build()
}
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.BugReport -> {
@ -241,10 +259,32 @@ class RootFlowNode(
backstack.pop()
}
}
bugReportEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
bugReportEntryPoint.nodeBuilder(this, buildContext).callback(callback).build()
}
is NavTarget.AccountSelect -> {
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) {
// Is there a session already?
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
switchToNotLoggedInFlow(params)
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
// Is there a session already?
val sessions = sessionStore.getAllSessions()
if (sessions.isNotEmpty()) {
if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
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 {
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
switchToNotLoggedInFlow(null)
switchToNotLoggedInFlow(params)
}
} else {
// Just ignore the login link if we already have a session
Timber.w("Login link ignored, we already have a session")
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
}
}
@ -290,56 +340,95 @@ class RootFlowNode(
// No session, open login
switchToNotLoggedInFlow(null)
} else {
attachSession(latestSessionId)
.attachIncomingShare(intent)
// wait for the current session to be restored
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) {
Timber.d("Navigating to $permalinkData")
attachSession(null)
.apply {
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 there a session already?
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
when (permalinkData) {
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
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,
)
)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
} else {
// Only one account, directly attach the room or the user node.
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) {
Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId)
.apply {
when (deeplinkData) {
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
}
attachSession(deeplinkData.sessionId).apply {
when (deeplinkData) {
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
}
}
}
private fun onOidcAction(oidcAction: OidcAction) {
oidcActionFlow.post(oidcAction)
}
// [sessionId] will be null for permalink.
private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode {
// TODO handle multi-session
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
// Ensure that the session is the latest one
sessionStore.setLatestSession(sessionId.value)
return waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId)
}
.attachSession()
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
}.attachSession()
}
}

View file

@ -10,7 +10,7 @@ package io.element.android.appnav.di
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -30,7 +30,7 @@ import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Inject
@AssistedInject
class SyncOrchestrator(
@Assisted matrixClient: MatrixClient,
private val appForegroundStateService: AppForegroundStateService,

View file

@ -14,12 +14,12 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class LoggedInNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -22,7 +22,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
@ -64,7 +64,7 @@ import java.util.Optional
import kotlin.jvm.optionals.getOrNull
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class RoomFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -25,7 +25,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.libraries.architecture.BackstackView
@ -45,7 +45,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class JoinedRoomFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -18,7 +18,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory
import io.element.android.appnav.room.RoomNavigationTarget
@ -45,7 +45,7 @@ import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class JoinedRoomLoadedFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -111,7 +111,7 @@ class IntentResolverTest {
@Test
fun `test resolve oidc`() {
val sut = createIntentResolver(
oidcIntentResolverResult = { OidcAction.GoBack },
oidcIntentResolverResult = { OidcAction.GoBack() },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@ -120,7 +120,7 @@ class IntentResolverTest {
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Oidc(
oidcAction = OidcAction.GoBack
oidcAction = OidcAction.GoBack()
)
)
}

View file

@ -35,6 +35,7 @@ import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.IntoMap
import dev.zacsweers.metro.Origin
import io.element.android.annotations.ContributesNode
import org.jetbrains.kotlin.name.FqName
@ -71,14 +72,16 @@ class ContributesNodeProcessor(
val scope = annotation.arguments.find { it.name?.asString() == "scope" }!!.value as KSType
val modulePackage = ksClass.packageName.asString()
val moduleClassName = "${ksClass.simpleName.asString()}_Module"
val nodeClassName = ClassName.bestGuess(ksClass.qualifiedName!!.asString())
val content = FileSpec.builder(
packageName = modulePackage,
fileName = moduleClassName,
)
.addType(
TypeSpec.interfaceBuilder(moduleClassName)
.addAnnotation(AnnotationSpec.builder(Origin::class).addMember(CLASS_PLACEHOLDER, nodeClassName).build())
.addAnnotation(BindingContainer::class)
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.toTypeName()).build())
.addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember(CLASS_PLACEHOLDER, scope.toTypeName()).build())
.addFunction(
FunSpec.builder("bind${ksClass.simpleName.asString()}Factory")
.addModifiers(KModifier.ABSTRACT)
@ -88,7 +91,7 @@ class ContributesNodeProcessor(
.addAnnotation(IntoMap::class)
.addAnnotation(
AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
"%T::class",
CLASS_PLACEHOLDER,
ClassName.bestGuess(ksClass.qualifiedName!!.asString())
).build()
)
@ -115,7 +118,7 @@ class ContributesNodeProcessor(
val assistedParameters = constructor.parameters.filter { it.isAnnotationPresent(Assisted::class) }
if (assistedParameters.size != 2) {
error(
"${ksClass.qualifiedName?.asString()} must have an @Inject constructor with 2 @Assisted parameters. Found: ${assistedParameters.size}",
"${ksClass.qualifiedName?.asString()} must have a constructor with 2 @Assisted parameters. Found: ${assistedParameters.size}",
)
}
val contextAssistedParam = assistedParameters[0]
@ -138,6 +141,7 @@ class ContributesNodeProcessor(
.addType(
TypeSpec.interfaceBuilder(assistedFactoryClassName)
.addSuperinterface(ClassName.bestGuess(assistedNodeFactoryFqName.asString()).parameterizedBy(nodeClassName))
.addAnnotation(AnnotationSpec.builder(Origin::class).addMember("%T::class", nodeClassName).build())
.addAnnotation(AssistedFactory::class)
.addFunction(
FunSpec.builder("create")
@ -161,6 +165,7 @@ class ContributesNodeProcessor(
}
companion object {
private const val CLASS_PLACEHOLDER = "%T::class"
private val assistedNodeFactoryFqName = FqName("io.element.android.libraries.architecture.AssistedNodeFactory")
private val nodeKeyFqName = FqName("io.element.android.libraries.architecture.NodeKey")
}

View file

@ -16,14 +16,14 @@ 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.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class AnalyticsOptInNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -19,7 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallType
@ -49,7 +49,7 @@ import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration.Companion.seconds
@Inject
@AssistedInject
class CallScreenPresenter(
@Assisted private val callType: CallType,
@Assisted private val navigator: CallScreenNavigator,

View file

@ -133,6 +133,7 @@ class WebViewWidgetMessageInterceptor(
return assetLoader.shouldInterceptRequest(request.url)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(url.toUri())
}

View file

@ -15,7 +15,7 @@ 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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs
@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.flow.first
@ContributesNode(RoomScope::class)
@Inject
@AssistedInject
class ChangeRolesNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -20,7 +20,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
@ -47,7 +47,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class ChangeRolesPresenter(
@Assisted private val role: RoomMember.Role,
private val room: JoinedRoom,

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
@ -32,7 +32,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class ChangeRoomMemberRolesRootNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class CreateRoomFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,7 +14,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleRenderer
@ -24,7 +24,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class AddPeopleNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -15,7 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class ConfigureRoomNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -13,12 +13,12 @@ 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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class AccountDeactivationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -8,7 +8,7 @@
<string name="screen_deactivate_account_list_item_1">"%1$s a fiókját (nem fog tudni újra bejelentkezni, és az azonosítója nem használható újra)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Véglegesen letiltja"</string>
<string name="screen_deactivate_account_list_item_2">"Eltávolításra kerül az összes csevegőszobából."</string>
<string name="screen_deactivate_account_list_item_3">"Törlésre kerülnek a fiókadatai a személyazonosító kiszolgálónkról."</string>
<string name="screen_deactivate_account_list_item_3">"Törlésre kerülnek a fiókadatai az azonosítási kiszolgálónkról."</string>
<string name="screen_deactivate_account_list_item_4">"Üzenetei továbbra is láthatóak maradnak a regisztrált felhasználók számára, de nem lesznek elérhetőek az új vagy nem regisztrált felhasználók számára, ha úgy dönt, hogy törli őket."</string>
<string name="screen_deactivate_account_title">"Fiók deaktiválása"</string>
</resources>

View file

@ -22,6 +22,7 @@ import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.Inject
import io.element.android.annotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
@ -42,7 +43,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class FtueFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,13 +14,13 @@ 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.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class NotificationsOptInNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,7 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.permissions.api.PermissionStateProvider
@ -25,7 +25,7 @@ import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class NotificationsOptInPresenter(
permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback,

View file

@ -21,7 +21,7 @@ import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode
@ -37,7 +37,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class FtueSessionVerificationFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,14 +14,14 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class ChooseSelfVerificationModeNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -71,6 +71,7 @@ dependencies {
testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)

View file

@ -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()
}
}

View file

@ -7,6 +7,9 @@
package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvents {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents
data class SwitchToAccount(val sessionId: SessionId) : HomeEvents
}

View file

@ -26,7 +26,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
@ -56,7 +56,7 @@ import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class HomeFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.matrix.api.MatrixClient
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
class HomePresenter(
@ -41,10 +46,21 @@ class HomePresenter(
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@Composable
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 canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
@ -71,6 +87,9 @@ class HomePresenter(
is HomeEvents.SelectHomeNavigationBarItem -> {
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()
return HomeState(
matrixUser = matrixUser.value,
currentUserAndNeighbors = currentUserAndNeighbors,
showAvatarIndicator = showAvatarIndicator,
hasNetworkConnection = isOnline,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,

View file

@ -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.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@Immutable
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 hasNetworkConnection: Boolean,
val currentHomeNavigationBarItem: HomeNavigationBarItem,

View file

@ -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.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList
open class HomeStateProvider : PreviewParameterProvider<HomeState> {
override val values: Sequence<HomeState>
@ -50,6 +51,7 @@ open class HomeStateProvider : PreviewParameterProvider<HomeState> {
internal fun aHomeState(
matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
currentUserAndNeighbors: List<MatrixUser> = listOf(matrixUser),
showAvatarIndicator: Boolean = false,
hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null,
@ -61,7 +63,7 @@ internal fun aHomeState(
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvents) -> Unit = {}
) = HomeState(
matrixUser = matrixUser,
currentUserAndNeighbors = currentUserAndNeighbors.toPersistentList(),
showAvatarIndicator = showAvatarIndicator,
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage,

View file

@ -171,12 +171,15 @@ private fun HomeScaffold(
topBar = {
RoomListTopBar(
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
matrixUser = state.matrixUser,
currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) },
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = {
state.eventSink(HomeEvents.SwitchToAccount(it))
},
scrollBehavior = scrollBehavior,
displayMenuItems = state.displayActions,
displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,

View file

@ -11,19 +11,25 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.libraries.designsystem.atomic.atoms.RedIndicatorAtom
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.AvatarType
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.MediumTopAppBar
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.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.testtags.TestTags
import io.element.android.libraries.testtags.testTag
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)
@Composable
fun RoomListTopBar(
title: String,
matrixUser: MatrixUser,
currentUserAndNeighbors: ImmutableList<MatrixUser>,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit,
onAccountSwitch: (SessionId) -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
displayMenuItems: Boolean,
displayFilters: Boolean,
@ -83,10 +94,11 @@ fun RoomListTopBar(
) {
DefaultRoomListTopBar(
title = title,
matrixUser = matrixUser,
currentUserAndNeighbors = currentUserAndNeighbors,
showAvatarIndicator = showAvatarIndicator,
areSearchResultsDisplayed = areSearchResultsDisplayed,
onOpenSettings = onOpenSettings,
onAccountSwitch = onAccountSwitch,
onSearchClick = onToggleSearch,
onMenuActionClick = onMenuActionClick,
scrollBehavior = scrollBehavior,
@ -102,11 +114,12 @@ fun RoomListTopBar(
@Composable
private fun DefaultRoomListTopBar(
title: String,
matrixUser: MatrixUser,
currentUserAndNeighbors: ImmutableList<MatrixUser>,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior,
onOpenSettings: () -> Unit,
onAccountSwitch: (SessionId) -> Unit,
onSearchClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
displayMenuItems: Boolean,
@ -116,12 +129,6 @@ private fun DefaultRoomListTopBar(
modifier: Modifier = Modifier,
) {
val collapsedFraction = scrollBehavior.state.collapsedFraction
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
Box(modifier = modifier) {
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
@ -158,8 +165,9 @@ private fun DefaultRoomListTopBar(
},
navigationIcon = {
NavigationIcon(
avatarData = avatarData,
currentUserAndNeighbors = currentUserAndNeighbors,
showAvatarIndicator = showAvatarIndicator,
onAccountSwitch = onAccountSwitch,
onClick = onOpenSettings,
)
},
@ -247,19 +255,67 @@ private fun DefaultRoomListTopBar(
@Composable
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,
onClick: () -> Unit,
) {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
modifier = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier,
onClick = onClick,
) {
Box {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
contentDescription = stringResource(CommonStrings.common_settings),
contentDescription = if (isCurrentAccount) stringResource(CommonStrings.common_settings) else null,
)
if (showAvatarIndicator) {
RedIndicatorAtom(
@ -276,11 +332,12 @@ private fun NavigationIcon(
internal fun DefaultRoomListTopBarPreview() = ElementPreview {
DefaultRoomListTopBar(
title = stringResource(R.string.screen_roomlist_main_space_title),
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onSearchClick = {},
displayMenuItems = true,
displayFilters = true,
@ -296,11 +353,33 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
DefaultRoomListTopBar(
title = stringResource(R.string.screen_roomlist_main_space_title),
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = true,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
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 = {},
displayMenuItems = true,
displayFilters = true,

View file

@ -6,8 +6,12 @@
<string name="screen_home_tab_chats">"Всички чатове"</string>
<string name="screen_invites_decline_chat_message">"Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Отказване на покана"</string>
<string name="screen_invites_decline_direct_chat_message">"Сигурни ли сте, че искате да откажете този личен чат с %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Отказване на чат"</string>
<string name="screen_invites_empty_list">"Няма покани"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) ви покани"</string>
<string name="screen_migration_message">"Това е еднократен процес, благодаря, че изчакахте."</string>
<string name="screen_migration_title">"Настройване на вашия акаунт."</string>
<string name="screen_roomlist_a11y_create_message">"Създаване на нов разговор или стая"</string>
<string name="screen_roomlist_empty_message">"Започнете, като изпратите съобщение на някого."</string>
<string name="screen_roomlist_empty_title">"Все още няма чатове."</string>

View file

@ -7,5 +7,5 @@
<string name="confirm_recovery_key_banner_primary_button_title">"Enter your backup password"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your backup password?"</string>
<string name="confirm_recovery_key_banner_title">"Your message backup is out of sync"</string>
<string name="session_verification_banner_message">"Looks like you\'re using a new device. Confirm it with another connected device to access your encrypted messages."</string>
<string name="session_verification_banner_message">"Looks like you\'re using a new device. Confirm it with another linked device to access your encrypted messages."</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Kapcsolja ki az alkalmazás akkumulátor-optimalizálását, hogy biztosan megkapja az összes értesítést."</string>
<string name="banner_battery_optimization_content_android">"Kapcsolja ki az alkalmazás akkumulátoroptimalizálását, hogy biztosan megkapja az összes értesítést."</string>
<string name="banner_battery_optimization_submit_android">"Optimalizálás letiltása"</string>
<string name="banner_battery_optimization_title_android">"Nem érkeznek meg az értesítések?"</string>
<string name="banner_set_up_recovery_content">"Hozzon létre egy új helyreállítási kulcsot, amellyel visszaállíthatja a titkosított üzenetek előzményeit, ha elveszíti az eszközökhöz való hozzáférést."</string>

View file

@ -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",
)
)
}
}

View file

@ -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.FakeMatrixClient
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.WarmUpRule
import io.element.android.tests.testutils.test
@ -54,17 +57,29 @@ class HomePresenterTest {
val presenter = createHomePresenter(
client = matrixClient,
rageshakeFeatureAvailability = { flowOf(false) },
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = matrixClient.sessionId.value,
userDisplayName = null,
userAvatarUrl = null,
)
),
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
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()
skipItems(1)
val withUserState = awaitItem()
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.currentUserAndNeighbors.first()).isEqualTo(
MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL)
)
assertThat(withUserState.showAvatarIndicator).isFalse()
assertThat(withUserState.isSpaceFeatureEnabled).isFalse()
assertThat(withUserState.showNavigationBar).isFalse()
@ -75,6 +90,9 @@ class HomePresenterTest {
fun `present - can report bug`() = runTest {
val presenter = createHomePresenter(
rageshakeFeatureAvailability = { flowOf(true) },
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -92,6 +110,9 @@ class HomePresenterTest {
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.Space.key to true),
),
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
presenter.test {
skipItems(1)
@ -105,6 +126,9 @@ class HomePresenterTest {
val indicatorService = FakeIndicatorService()
val presenter = createHomePresenter(
indicatorService = indicatorService,
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -124,19 +148,28 @@ class HomePresenterTest {
userAvatarUrl = null,
)
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
val presenter = createHomePresenter(client = matrixClient)
val presenter = createHomePresenter(
client = matrixClient,
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming
}
}
@Test
fun `present - NavigationBar change`() = runTest {
val presenter = createHomePresenter()
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -152,6 +185,9 @@ class HomePresenterTest {
fun `present - NavigationBar is hidden when the last space is left`() = runTest {
val homeSpacesPresenter = MutablePresenter(aHomeSpacesState())
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.Space.key to true),
),
@ -185,6 +221,7 @@ internal fun createHomePresenter(
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
sessionStore: SessionStore = InMemorySessionStore(),
) = HomePresenter(
client = client,
syncService = syncService,
@ -195,4 +232,5 @@ internal fun createHomePresenter(
homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
sessionStore = sessionStore,
)

View file

@ -13,7 +13,7 @@ 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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.architecture.NodeInputs
@ -21,7 +21,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class DeclineAndBlockNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -17,7 +17,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.impl.DeclineInvite
import io.element.android.libraries.architecture.AsyncAction
@ -28,7 +28,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class DeclineAndBlockPresenter(
@Assisted private val inviteData: InviteData,
private val declineInvite: DeclineInvite,

View file

@ -3,6 +3,8 @@
<string name="screen_decline_and_block_block_user_option_title">"Блокиране на потребителя"</string>
<string name="screen_invites_decline_chat_message">"Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Отказване на покана"</string>
<string name="screen_invites_decline_direct_chat_message">"Сигурни ли сте, че искате да откажете този личен чат с %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Отказване на чат"</string>
<string name="screen_invites_empty_list">"Няма покани"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) ви покани"</string>
</resources>

View file

@ -4,7 +4,7 @@
<string name="screen_decline_and_block_block_user_option_title">"Felhasználó letiltása"</string>
<string name="screen_decline_and_block_report_user_option_description">"A szoba jelentése a fiókszolgáltatójának."</string>
<string name="screen_decline_and_block_report_user_reason_placeholder">"Írja le a jelentés okát…"</string>
<string name="screen_decline_and_block_title">"Elutasítás és blokkolás"</string>
<string name="screen_decline_and_block_title">"Elutasítás és letiltás"</string>
<string name="screen_invites_decline_chat_message">"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Meghívás elutasítása"</string>
<string name="screen_invites_decline_direct_chat_message">"Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?"</string>

View file

@ -18,8 +18,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleState
@ -51,7 +51,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Inject
@AssistedInject
class DefaultInvitePeoplePresenter(
@Assisted private val joinedRoom: JoinedRoom?,
@Assisted private val roomId: RoomId,

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
@ -30,7 +30,7 @@ import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class JoinRoomFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -21,7 +21,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.SeenInvitesStore
@ -59,7 +59,7 @@ import kotlinx.coroutines.launch
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
@Inject
@AssistedInject
class JoinRoomPresenter(
@Assisted private val roomId: RoomId,
@Assisted private val roomIdOrAlias: RoomIdOrAlias,

View file

@ -15,6 +15,7 @@
<string name="screen_join_room_fail_reason">"Tato místnost je buď určena pouze pro zvané, nebo do ní může být omezen přístup na úrovni prostoru."</string>
<string name="screen_join_room_forget_action">"Zapomenout na tuto místnost"</string>
<string name="screen_join_room_invite_required_message">"Abyste se mohli připojit k této místnosti, potřebujete pozvánku."</string>
<string name="screen_join_room_invited_by">"Pozván(a)"</string>
<string name="screen_join_room_join_action">"Připojit se do místnosti"</string>
<string name="screen_join_room_join_restricted_message">"Abyste se mohli připojit, musíte být pozváni nebo být členem některého prostoru."</string>
<string name="screen_join_room_knock_action">"Zaklepejte a připojte se"</string>

View file

@ -15,6 +15,7 @@
<string name="screen_join_room_fail_reason">"Dette rum er enten kun for gæster, eller der kan være sat begrænsninger for adgangen på klyngeniveau."</string>
<string name="screen_join_room_forget_action">"Glem dette rum"</string>
<string name="screen_join_room_invite_required_message">"Du har brug for en invitation for at deltage i dette rum"</string>
<string name="screen_join_room_invited_by">"Inviteret af"</string>
<string name="screen_join_room_join_action">"Deltag i rummet"</string>
<string name="screen_join_room_join_restricted_message">"Du skal muligvis være inviteret eller være medlem af en klynge for at deltage."</string>
<string name="screen_join_room_knock_action">"Send anmodning om at deltage"</string>

View file

@ -15,6 +15,7 @@
<string name="screen_join_room_fail_reason">"Dieser Chat ist entweder nur auf Einladung zugänglich oder es gibt andere Zugangsbeschränkungen durch Spaces."</string>
<string name="screen_join_room_forget_action">"Vergiss diesen Chat"</string>
<string name="screen_join_room_invite_required_message">"Du benötigst eine Einladung, um diesem Chat beizutreten"</string>
<string name="screen_join_room_invited_by">"Eingeladen von"</string>
<string name="screen_join_room_join_action">"Chat beitreten"</string>
<string name="screen_join_room_join_restricted_message">"Möglicherweise musst du eingeladen werden oder ein Mitglied eines Spaces sein, um beitreten zu können."</string>
<string name="screen_join_room_knock_action">"Anklopfen"</string>

View file

@ -15,6 +15,7 @@
<string name="screen_join_room_fail_reason">"Ligipääs siia jututuppa on võimalik vaid kutse alusel või kehtivad siin kogukonnakohased piirangud."</string>
<string name="screen_join_room_forget_action">"Unusta see jututuba"</string>
<string name="screen_join_room_invite_required_message">"Selle jututoaga liitumiseks vajad sa kutset"</string>
<string name="screen_join_room_invited_by">"Kutsuja"</string>
<string name="screen_join_room_join_action">"Liitu jututoaga"</string>
<string name="screen_join_room_join_restricted_message">"Selle jututoaga liitumiseks sa vajad kutset või pead juba olema kogukonna liige."</string>
<string name="screen_join_room_knock_action">"Liitumiseks koputa jututoa uksele"</string>

View file

@ -15,6 +15,7 @@
<string name="screen_join_room_fail_reason">"Ce salon est accessible uniquement sur invitation ou il peut y avoir des restrictions daccès au niveau de lespace."</string>
<string name="screen_join_room_forget_action">"Oublier ce salon"</string>
<string name="screen_join_room_invite_required_message">"Vous avez besoin dune invitation pour rejoindre ce salon"</string>
<string name="screen_join_room_invited_by">"Invité(e) par"</string>
<string name="screen_join_room_join_action">"Rejoindre"</string>
<string name="screen_join_room_join_restricted_message">"Il est possible que vous deviez être invité ou être membre dun Espace pour pouvoir rejoindre le salon."</string>
<string name="screen_join_room_knock_action">"Demander à joindre"</string>

View file

@ -15,6 +15,7 @@
<string name="screen_join_room_fail_reason">"Ebbe a szobába csak meghívóval vagy tértagsággal lehet belépni."</string>
<string name="screen_join_room_forget_action">"Szoba elfelejtése"</string>
<string name="screen_join_room_invite_required_message">"Meghívóra van szüksége ahhoz, hogy csatlakozzon ehhez a szobához"</string>
<string name="screen_join_room_invited_by">"Meghívta:"</string>
<string name="screen_join_room_join_action">"Csatlakozás a szobához"</string>
<string name="screen_join_room_join_restricted_message">"A csatlakozáshoz meghívásra vagy tértagságra lehet szüksége."</string>
<string name="screen_join_room_knock_action">"Kopogtasson a csatlakozáshoz"</string>

View file

@ -15,6 +15,7 @@
<string name="screen_join_room_fail_reason">"A entrada nesta sala ou está limitada a convites ou a alguma configuração de espaço."</string>
<string name="screen_join_room_forget_action">"Esquecer esta sala"</string>
<string name="screen_join_room_invite_required_message">"Precisas de um convite para entrares nesta sala"</string>
<string name="screen_join_room_invited_by">"Convidado por"</string>
<string name="screen_join_room_join_action">"Entrar na sala"</string>
<string name="screen_join_room_join_restricted_message">"Podes ter que ser convidado ou pertenceres a um espaço para poderes entrar."</string>
<string name="screen_join_room_knock_action">"Bater à porta"</string>

View file

@ -13,12 +13,12 @@ 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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
@Inject
@AssistedInject
class KnockRequestsListNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"Сигурни ли сте, че искате да напуснете тази стая? Вие сте единственият човек тук. Ако напуснете, никой няма да може да се присъедини в бъдеще, включително и вие."</string>
<string name="leave_room_alert_private_subtitle">"Сигурни ли сте, че искате да напуснете тази стая? Тази стая не е общодостъпна и няма да можете да се присъедините отново без покана."</string>
<string name="leave_room_alert_subtitle">"Сигурни ли сте, че искате да напуснете стаята?"</string>
</resources>

View file

@ -17,7 +17,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.licenses.impl.details.DependenciesDetailsNode
import io.element.android.features.licenses.impl.list.DependencyLicensesListNode
@ -28,7 +28,7 @@ import io.element.android.libraries.architecture.createNode
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class DependenciesFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,14 +14,14 @@ 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.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class DependenciesDetailsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -15,12 +15,12 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class DependencyLicensesListNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,11 +14,11 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
@Suppress("unused")
@Inject
@AssistedInject
class DefaultPermissionsPresenter(
@Assisted private val permissions: List<String>
) : PermissionsPresenter {

View file

@ -14,7 +14,7 @@ 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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
@Inject
@AssistedInject
class SendLocationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -17,7 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
@ -36,7 +36,7 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class SendLocationPresenter(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val room: JoinedRoom,

View file

@ -14,7 +14,7 @@ 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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.location.api.ShowLocationEntryPoint
@ -23,7 +23,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
@Inject
@AssistedInject
class ShowLocationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -16,7 +16,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
@ -26,7 +26,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@Inject
@AssistedInject
class ShowLocationPresenter(
@Assisted private val location: Location,
@Assisted private val description: String?,

View file

@ -16,7 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
@ -29,7 +29,7 @@ import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class LockScreenFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -20,7 +20,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@ -35,7 +35,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class LockScreenSettingsFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,12 +14,12 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class LockScreenSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -18,7 +18,7 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
@ -32,7 +32,7 @@ import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class LockScreenSetupFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -15,12 +15,12 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class SetupBiometricNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -13,12 +13,12 @@ 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.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class SetupPinNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -15,12 +15,12 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class PinUnlockNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -1,7 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_app_lock_biometric_authentication">"биометрично удостоверяване"</string>
<string name="screen_app_lock_biometric_unlock">"биометрично отключване"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Отключване с биометрия"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Потвърдете биометричните данни"</string>
<string name="screen_app_lock_forgot_pin">"Забравихте PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Промяна на PIN кода"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Разрешаване на биометрично отключване"</string>
<string name="screen_app_lock_settings_remove_pin">"Премахване на PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Сигурни ли сте, че искате да премахнете PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Премахване на PIN?"</string>
@ -9,6 +14,10 @@
<string name="screen_app_lock_setup_biometric_unlock_skip">"Предпочитам да използвам PIN"</string>
<string name="screen_app_lock_setup_choose_pin">"Избор на PIN"</string>
<string name="screen_app_lock_setup_confirm_pin">"Потвърждаване на PIN"</string>
<string name="screen_app_lock_setup_pin_context">"Заключете %1$s, за да добавите допълнителна сигурност към вашите чатове.
Изберете нещо запомнящо се. Ако забравите този PIN, ще бъдете излезли от приложението."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Не можете да изберете това за ваш PIN код от съображения за сигурност"</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Избор на различен PIN"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Моля, въведете един и същ PIN два пъти"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINs не съвпадат"</string>
@ -20,6 +29,7 @@
<item quantity="one">"Грешен PIN. Имате още %1$d шанс"</item>
<item quantity="other">"Грешен PIN. Имате още %1$d шанса"</item>
</plurals>
<string name="screen_app_lock_use_biometric_android">"Използване на биометрия"</string>
<string name="screen_app_lock_use_pin_android">"Използване на PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Излизане…"</string>
</resources>

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.qrcode)
implementation(projects.libraries.oidc.api)
implementation(projects.libraries.uiUtils)
@ -56,5 +57,6 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
}

View file

@ -24,7 +24,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.api.LoginEntryPoint
@ -51,7 +51,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class LoginFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ -87,7 +87,7 @@ class LoginFlowNode(
// by pressing back or by closing the Custom Chrome Tab.
lifecycleScope.launch {
delay(5000)
oidcActionFlow.post(OidcAction.GoBack)
oidcActionFlow.post(OidcAction.GoBack(toUnblock = true))
}
}
}

View file

@ -94,9 +94,14 @@ class LoginHelper(
}
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()
when (oidcAction) {
OidcAction.GoBack -> {
is OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loginModeState.value = AsyncData.Uninitialized

View file

@ -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.dialogs.SlidingSyncNotSupportedDialog
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.libraries.androidutils.system.openGooglePlay
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.PreviewsDayNight
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.ui.strings.CommonStrings
@ -89,6 +89,12 @@ fun LoginModeView(
onSubmit = onClearError,
)
}
is AuthenticationException.AccountAlreadyLoggedIn -> {
ErrorDialog(
content = stringResource(CommonStrings.error_account_already_logged_in, error.message.orEmpty()),
onSubmit = onClearError,
)
}
else -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
@ -113,7 +119,7 @@ fun LoginModeView(
@PreviewsDayNight
@Composable
internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) {
internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) {
ElementPreview {
LoginModeView(
loginMode = AsyncData.Failure(error),

View 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.
*/
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")
}

View file

@ -23,7 +23,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginBindings
import io.element.android.features.login.impl.di.QrCodeLoginGraph
@ -49,7 +49,7 @@ import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class QrCodeLoginFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -16,12 +16,12 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class ChangeAccountProviderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -16,13 +16,13 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class ChooseAccountProviderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -16,7 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.NodeInputs
@ -24,7 +24,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class ConfirmAccountProviderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -13,12 +13,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.libraries.architecture.Presenter
@Inject
@AssistedInject
class ConfirmAccountProviderPresenter(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,

View file

@ -16,7 +16,7 @@ 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.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
@ -24,7 +24,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class CreateAccountNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -15,7 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.tryOrNull
@ -31,7 +31,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration.Companion.seconds
@Inject
@AssistedInject
class CreateAccountPresenter(
@Assisted private val url: String,
private val authenticationService: MatrixAuthenticationService,

View file

@ -14,11 +14,11 @@ 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.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class LoginPasswordNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -16,7 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.NodeInputs
@ -24,7 +24,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
@Inject
@AssistedInject
class OnBoardingNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ -97,6 +97,7 @@ class OnBoardingNode(
onNeedLoginPassword = ::onLoginPasswordNeeded,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = ::onCreateAccountContinue,
onBackClick = ::navigateUp,
)
}
}

View file

@ -18,7 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
@ -27,9 +27,10 @@ import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
@Inject
@AssistedInject
class OnBoardingPresenter(
@Assisted private val params: OnBoardingNode.Params,
private val buildMeta: BuildMeta,
@ -38,6 +39,7 @@ class OnBoardingPresenter(
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper,
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
private val sessionStore: SessionStore,
) : Presenter<OnBoardingState> {
@AssistedFactory
interface Factory {
@ -86,6 +88,10 @@ class OnBoardingPresenter(
val onBoardingLogoResId = remember {
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()
@ -109,6 +115,7 @@ class OnBoardingPresenter(
}
return OnBoardingState(
isAddingAccount = isAddingAccount,
productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,

View file

@ -12,6 +12,7 @@ import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
val isAddingAccount: Boolean,
val productionApplicationName: String,
val defaultAccountProvider: String?,
val mustChooseAccountProvider: Boolean,

View file

@ -23,10 +23,16 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true),
anOnBoardingState(customLogoResId = R.drawable.sample_background),
anOnBoardingState(
isAddingAccount = true,
canLoginWithQrCode = true,
canCreateAccount = true,
),
)
}
fun anOnBoardingState(
isAddingAccount: Boolean = false,
productionApplicationName: String = "Element",
defaultAccountProvider: String? = null,
mustChooseAccountProvider: Boolean = false,
@ -39,6 +45,7 @@ fun anOnBoardingState(
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
isAddingAccount = isAddingAccount,
productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,

View file

@ -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.ElementLogoAtomSize
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.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@ -58,6 +60,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun OnBoardingView(
state: OnBoardingState,
onBackClick: () -> Unit,
onSignInWithQrCode: () -> Unit,
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit,
@ -67,6 +70,52 @@ fun OnBoardingView(
onCreateAccountContinue: (url: String) -> Unit,
onReportProblem: () -> Unit,
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(
modifier = modifier,
@ -79,29 +128,31 @@ fun OnBoardingView(
} else {
OnBoardingContent(state = state)
}
LoginModeView(
loginMode = state.loginMode,
onClearError = {
state.eventSink(OnBoardingEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
loginView()
},
footer = {
OnBoardingButtons(
state = state,
onSignInWithQrCode = onSignInWithQrCode,
onSignIn = onSignIn,
onCreateAccount = onCreateAccount,
onReportProblem = onReportProblem,
)
buttons()
}
)
}
@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
private fun OnBoardingContent(state: OnBoardingState) {
Box(
@ -226,27 +277,29 @@ private fun OnBoardingButtons(
.fillMaxWidth()
)
}
if (state.canReportBug) {
// Add a report problem text button. Use a Text since we need a special theme here.
Text(
modifier = Modifier
.clickable(onClick = onReportProblem)
.padding(16.dp),
text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
} else {
Text(
modifier = Modifier
.clickable {
state.eventSink(OnBoardingEvents.OnVersionClick)
}
.padding(16.dp),
text = stringResource(id = R.string.screen_onboarding_app_version, state.version),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
if (state.isAddingAccount.not()) {
if (state.canReportBug) {
// Add a report problem text button. Use a Text since we need a special theme here.
Text(
modifier = Modifier
.clickable(onClick = onReportProblem)
.padding(16.dp),
text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
} else {
Text(
modifier = Modifier
.clickable {
state.eventSink(OnBoardingEvents.OnVersionClick)
}
.padding(16.dp),
text = stringResource(id = R.string.screen_onboarding_app_version, state.version),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
@ -258,6 +311,7 @@ internal fun OnBoardingViewPreview(
) = ElementPreview {
OnBoardingView(
state = state,
onBackClick = {},
onSignInWithQrCode = {},
onSignIn = {},
onCreateAccount = {},

View file

@ -14,13 +14,13 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
import io.element.android.libraries.architecture.inputs
@ContributesNode(QrCodeLoginScope::class)
@Inject
@AssistedInject
class QrCodeConfirmationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,7 +14,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
@ -22,7 +22,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.meta.BuildMeta
@ContributesNode(QrCodeLoginScope::class)
@Inject
@AssistedInject
class QrCodeErrorNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,12 +14,12 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
@ContributesNode(QrCodeLoginScope::class)
@Inject
@AssistedInject
class QrCodeIntroNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,13 +14,13 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.di.QrCodeLoginScope
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
@ContributesNode(QrCodeLoginScope::class)
@Inject
@AssistedInject
class QrCodeScanNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

Some files were not shown because too many files have changed in this diff Show more