Merge branch 'develop' into feature/fga/pinned_messages_list_remove_reaction
This commit is contained in:
commit
e059cdd916
82 changed files with 375 additions and 907 deletions
32
CHANGES.md
32
CHANGES.md
|
|
@ -1,3 +1,35 @@
|
||||||
|
Changes in Element X v0.5.3 (2024-09-10)
|
||||||
|
========================================
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
* Add banner for optional migration to simplified sliding sync by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3429
|
||||||
|
|
||||||
|
### 🙌 Improvements
|
||||||
|
* Timeline : remove the encrypted history banner by @ganfra in https://github.com/element-hq/element-x-android/pull/3410
|
||||||
|
|
||||||
|
### 🐛 Bugfixes
|
||||||
|
* Fix new logins with Simplified SS using the proxy by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3417
|
||||||
|
* Ensure Call is not hang up when user is asked to grant system permissions by @bmarty in https://github.com/element-hq/element-x-android/pull/3419
|
||||||
|
* Wait for a room with joined state in `/sync` after creating it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3421
|
||||||
|
* [Bugfix] : fix self verification flow by @ganfra in https://github.com/element-hq/element-x-android/pull/3426
|
||||||
|
|
||||||
|
### 🗣 Translations
|
||||||
|
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3425
|
||||||
|
|
||||||
|
### 🚧 In development 🚧
|
||||||
|
* [Feature] Pinned messages list by @ganfra in https://github.com/element-hq/element-x-android/pull/3392
|
||||||
|
* Pinned messages banner : adjust indicator to match design. by @ganfra in https://github.com/element-hq/element-x-android/pull/3415
|
||||||
|
|
||||||
|
### Dependency upgrades
|
||||||
|
* Update plugin dependencycheck to v10.0.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3372
|
||||||
|
* Update plugin detekt to v1.23.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3424
|
||||||
|
|
||||||
|
### Others
|
||||||
|
* Delete old log files by @bmarty in https://github.com/element-hq/element-x-android/pull/3413
|
||||||
|
* Recovery key formatting and wording iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/3409
|
||||||
|
* Change license to AGPL by @bmarty in https://github.com/element-hq/element-x-android/pull/3422
|
||||||
|
* Remove Wait list screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3428
|
||||||
|
|
||||||
Changes in Element X v0.5.2 (2024-09-05)
|
Changes in Element X v0.5.2 (2024-09-05)
|
||||||
=========================================
|
=========================================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||||
import io.element.android.features.ftue.api.state.FtueService
|
import io.element.android.features.ftue.api.state.FtueService
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
|
import io.element.android.features.logout.api.LogoutEntryPoint
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||||
|
|
@ -65,6 +66,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||||
|
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
|
@ -96,6 +98,8 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
private val shareEntryPoint: ShareEntryPoint,
|
private val shareEntryPoint: ShareEntryPoint,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val sendingQueue: SendQueues,
|
private val sendingQueue: SendQueues,
|
||||||
|
private val logoutEntryPoint: LogoutEntryPoint,
|
||||||
|
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||||
snackbarDispatcher: SnackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -225,6 +229,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class IncomingShare(val intent: Intent) : NavTarget
|
data class IncomingShare(val intent: Intent) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -271,6 +278,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
override fun onRoomDirectorySearchClick() {
|
override fun onRoomDirectorySearchClick() {
|
||||||
backstack.push(NavTarget.RoomDirectorySearch)
|
backstack.push(NavTarget.RoomDirectorySearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
|
||||||
|
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
roomListEntryPoint
|
roomListEntryPoint
|
||||||
.nodeBuilder(this, buildContext)
|
.nodeBuilder(this, buildContext)
|
||||||
|
|
@ -407,6 +418,20 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
.params(ShareEntryPoint.Params(intent = navTarget.intent))
|
.params(ShareEntryPoint.Params(intent = navTarget.intent))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
is NavTarget.LogoutForNativeSlidingSyncMigrationNeeded -> {
|
||||||
|
val callback = object : LogoutEntryPoint.Callback {
|
||||||
|
override fun onChangeRecoveryKeyClick() {
|
||||||
|
backstack.push(NavTarget.SecureBackup())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logoutEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
.onSuccessfulLogoutPendingAction {
|
||||||
|
enableNativeSlidingSyncUseCase()
|
||||||
|
}
|
||||||
|
.callback(callback)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2
fastlane/metadata/android/en-US/changelogs/40005030.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40005030.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Main changes in this version: mainly bug fixes.
|
||||||
|
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||||
|
|
@ -19,7 +19,6 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
|
|
@ -31,10 +30,8 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
|
||||||
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
||||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
||||||
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.waitlistscreen.WaitListNode
|
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
@ -112,9 +109,6 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object LoginPassword : NavTarget
|
data object LoginPassword : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class WaitList(val loginFormState: LoginFormState) : NavTarget
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
@ -181,27 +175,11 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.LoginPassword -> {
|
NavTarget.LoginPassword -> {
|
||||||
val callback = object : LoginPasswordNode.Callback {
|
createNode<LoginPasswordNode>(buildContext)
|
||||||
override fun onWaitListError(loginFormState: LoginFormState) {
|
|
||||||
backstack.newRoot(NavTarget.WaitList(loginFormState))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createNode<LoginPasswordNode>(buildContext, plugins = listOf(callback))
|
|
||||||
}
|
}
|
||||||
is NavTarget.OidcView -> {
|
is NavTarget.OidcView -> {
|
||||||
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
|
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
|
||||||
}
|
}
|
||||||
is NavTarget.WaitList -> {
|
|
||||||
val inputs = WaitListNode.Inputs(
|
|
||||||
loginFormState = navTarget.loginFormState,
|
|
||||||
)
|
|
||||||
val callback = object : WaitListNode.Callback {
|
|
||||||
override fun onCancelClick() {
|
|
||||||
navigateUp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createNode<WaitListNode>(buildContext, plugins = listOf(callback, inputs))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.error
|
|
||||||
|
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
|
||||||
|
|
||||||
fun Throwable.isWaitListError(): Boolean {
|
|
||||||
return message?.contains("IO_ELEMENT_X_WAIT_LIST").orFalse()
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
|
@ -24,14 +23,6 @@ class LoginPasswordNode @AssistedInject constructor(
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: LoginPasswordPresenter,
|
private val presenter: LoginPasswordPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
interface Callback : Plugin {
|
|
||||||
fun onWaitListError(loginFormState: LoginFormState)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onWaitListError(loginFormState: LoginFormState) {
|
|
||||||
plugins<Callback>().forEach { it.onWaitListError(loginFormState) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -39,7 +30,6 @@ class LoginPasswordNode @AssistedInject constructor(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = ::navigateUp,
|
onBackClick = ::navigateUp,
|
||||||
onWaitListError = ::onWaitListError,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.error.isWaitListError
|
|
||||||
import io.element.android.features.login.impl.error.loginError
|
import io.element.android.features.login.impl.error.loginError
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
|
|
@ -72,7 +71,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
fun LoginPasswordView(
|
fun LoginPasswordView(
|
||||||
state: LoginPasswordState,
|
state: LoginPasswordState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onWaitListError: (LoginFormState) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isLoading by remember(state.loginAction) {
|
val isLoading by remember(state.loginAction) {
|
||||||
|
|
@ -149,16 +147,9 @@ fun LoginPasswordView(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.loginAction is AsyncData.Failure) {
|
if (state.loginAction is AsyncData.Failure) {
|
||||||
when {
|
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
|
||||||
state.loginAction.error.isWaitListError() -> {
|
state.eventSink(LoginPasswordEvents.ClearError)
|
||||||
onWaitListError(state.formState)
|
})
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
|
|
||||||
state.eventSink(LoginPasswordEvents.ClearError)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -302,6 +293,5 @@ internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStateProvid
|
||||||
LoginPasswordView(
|
LoginPasswordView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
onWaitListError = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
|
||||||
|
|
||||||
sealed interface WaitListEvents {
|
|
||||||
data object AttemptLogin : WaitListEvents
|
|
||||||
data object ClearError : WaitListEvents
|
|
||||||
data object Continue : WaitListEvents
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
|
||||||
import com.bumble.appyx.core.node.Node
|
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
|
||||||
import io.element.android.libraries.architecture.inputs
|
|
||||||
import io.element.android.libraries.di.AppScope
|
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
|
||||||
class WaitListNode @AssistedInject constructor(
|
|
||||||
@Assisted buildContext: BuildContext,
|
|
||||||
@Assisted plugins: List<Plugin>,
|
|
||||||
presenterFactory: WaitListPresenter.Factory,
|
|
||||||
) : Node(buildContext, plugins = plugins) {
|
|
||||||
data class Inputs(val loginFormState: LoginFormState) : NodeInputs
|
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
|
||||||
private val presenter = presenterFactory.create(inputs.loginFormState)
|
|
||||||
|
|
||||||
interface Callback : Plugin {
|
|
||||||
fun onCancelClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onCancelClick() {
|
|
||||||
plugins<Callback>().forEach { it.onCancelClick() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun View(modifier: Modifier) {
|
|
||||||
val state = presenter.present()
|
|
||||||
WaitListView(
|
|
||||||
state = state,
|
|
||||||
onCancelClick = ::onCancelClick,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedFactory
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import io.element.android.libraries.architecture.Presenter
|
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
class WaitListPresenter @AssistedInject constructor(
|
|
||||||
@Assisted private val formState: LoginFormState,
|
|
||||||
private val buildMeta: BuildMeta,
|
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
|
||||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
|
||||||
) : Presenter<WaitListState> {
|
|
||||||
@AssistedFactory
|
|
||||||
interface Factory {
|
|
||||||
fun create(loginFormState: LoginFormState): WaitListPresenter
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun present(): WaitListState {
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val homeserverUrl = remember {
|
|
||||||
authenticationService.getHomeserverDetails().value?.url ?: "server"
|
|
||||||
}
|
|
||||||
|
|
||||||
val loginAction: MutableState<AsyncData<SessionId>> = remember {
|
|
||||||
mutableStateOf(AsyncData.Uninitialized)
|
|
||||||
}
|
|
||||||
|
|
||||||
val attemptNumber = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
fun handleEvents(event: WaitListEvents) {
|
|
||||||
when (event) {
|
|
||||||
WaitListEvents.AttemptLogin -> {
|
|
||||||
// Do not attempt to login on first resume of the View.
|
|
||||||
attemptNumber.intValue++
|
|
||||||
if (attemptNumber.intValue > 1) {
|
|
||||||
coroutineScope.loginAttempt(formState, loginAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WaitListEvents.ClearError -> loginAction.value = AsyncData.Uninitialized
|
|
||||||
WaitListEvents.Continue -> defaultLoginUserStory.setLoginFlowIsDone(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return WaitListState(
|
|
||||||
appName = buildMeta.applicationName,
|
|
||||||
serverName = homeserverUrl,
|
|
||||||
loginAction = loginAction.value,
|
|
||||||
eventSink = ::handleEvents
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CoroutineScope.loginAttempt(formState: LoginFormState, loggedInState: MutableState<AsyncData<SessionId>>) = launch {
|
|
||||||
Timber.w("Attempt to login...")
|
|
||||||
loggedInState.value = AsyncData.Loading()
|
|
||||||
authenticationService.login(formState.login.trim(), formState.password)
|
|
||||||
.onSuccess { sessionId ->
|
|
||||||
loggedInState.value = AsyncData.Success(sessionId)
|
|
||||||
}
|
|
||||||
.onFailure { failure ->
|
|
||||||
loggedInState.value = AsyncData.Failure(failure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
|
||||||
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
|
|
||||||
// Do not use default value, so no member get forgotten in the presenters.
|
|
||||||
data class WaitListState(
|
|
||||||
val appName: String,
|
|
||||||
val serverName: String,
|
|
||||||
val loginAction: AsyncData<SessionId>,
|
|
||||||
val eventSink: (WaitListEvents) -> Unit
|
|
||||||
)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
|
|
||||||
open class WaitListStateProvider : PreviewParameterProvider<WaitListState> {
|
|
||||||
override val values: Sequence<WaitListState>
|
|
||||||
get() = sequenceOf(
|
|
||||||
aWaitListState(loginAction = AsyncData.Uninitialized),
|
|
||||||
aWaitListState(loginAction = AsyncData.Loading()),
|
|
||||||
aWaitListState(loginAction = AsyncData.Failure(Throwable("error"))),
|
|
||||||
aWaitListState(loginAction = AsyncData.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))),
|
|
||||||
aWaitListState(loginAction = AsyncData.Success(SessionId("@alice:element.io"))),
|
|
||||||
// Add other state here
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun aWaitListState(
|
|
||||||
appName: String = "Element X",
|
|
||||||
serverName: String = "server.org",
|
|
||||||
loginAction: AsyncData<SessionId> = AsyncData.Uninitialized,
|
|
||||||
) = WaitListState(
|
|
||||||
appName = appName,
|
|
||||||
serverName = serverName,
|
|
||||||
loginAction = loginAction,
|
|
||||||
eventSink = {}
|
|
||||||
)
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.features.login.impl.R
|
|
||||||
import io.element.android.features.login.impl.error.isWaitListError
|
|
||||||
import io.element.android.features.login.impl.error.loginError
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
|
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
|
||||||
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
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425
|
|
||||||
// Only the first screen can be displayed, since once logged in, this Node will be remove by the RootNode.
|
|
||||||
@Composable
|
|
||||||
fun WaitListView(
|
|
||||||
state: WaitListState,
|
|
||||||
onCancelClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
OnLifecycleEvent { _, event ->
|
|
||||||
when (event) {
|
|
||||||
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin)
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WaitListContent(state, onCancelClick, modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WaitListError(state: WaitListState) {
|
|
||||||
// Display a dialog for error other than the waitlist error
|
|
||||||
state.loginAction.errorOrNull()?.let { error ->
|
|
||||||
if (error.isWaitListError().not()) {
|
|
||||||
RetryDialog(
|
|
||||||
content = stringResource(id = loginError(error)),
|
|
||||||
onRetry = {
|
|
||||||
state.eventSink.invoke(WaitListEvents.AttemptLogin)
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
state.eventSink.invoke(WaitListEvents.ClearError)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WaitListContent(
|
|
||||||
state: WaitListState,
|
|
||||||
onCancelClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
val title = stringResource(
|
|
||||||
when (state.loginAction) {
|
|
||||||
is AsyncData.Success -> R.string.screen_waitlist_title_success
|
|
||||||
else -> R.string.screen_waitlist_title
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val subtitle = when (state.loginAction) {
|
|
||||||
is AsyncData.Success -> stringResource(
|
|
||||||
id = R.string.screen_waitlist_message_success,
|
|
||||||
state.appName,
|
|
||||||
)
|
|
||||||
else -> stringResource(
|
|
||||||
id = R.string.screen_waitlist_message,
|
|
||||||
state.appName,
|
|
||||||
state.serverName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SunsetPage(
|
|
||||||
isLoading = state.loginAction.isLoading(),
|
|
||||||
title = title,
|
|
||||||
subtitle = subtitle,
|
|
||||||
) {
|
|
||||||
OverallContent(state, onCancelClick)
|
|
||||||
}
|
|
||||||
WaitListError(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun OverallContent(
|
|
||||||
state: WaitListState,
|
|
||||||
onCancelClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
if (state.loginAction !is AsyncData.Success) {
|
|
||||||
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textOnSolidPrimary) {
|
|
||||||
TextButton(
|
|
||||||
text = stringResource(CommonStrings.action_cancel),
|
|
||||||
onClick = onCancelClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.loginAction is AsyncData.Success) {
|
|
||||||
Button(
|
|
||||||
text = stringResource(id = CommonStrings.action_continue),
|
|
||||||
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun WaitListViewPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = ElementPreview {
|
|
||||||
WaitListView(
|
|
||||||
state = state,
|
|
||||||
onCancelClick = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.waitlistscreen
|
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class WaitListPresenterTest {
|
|
||||||
@get:Rule
|
|
||||||
val warmUpRule = WarmUpRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - initial state`() = runTest {
|
|
||||||
val authenticationService = FakeMatrixAuthenticationService().apply {
|
|
||||||
givenHomeserver(A_HOMESERVER)
|
|
||||||
}
|
|
||||||
val loginUserStory = DefaultLoginUserStory()
|
|
||||||
val presenter = WaitListPresenter(
|
|
||||||
LoginFormState.Default,
|
|
||||||
aBuildMeta(applicationName = "Application Name"),
|
|
||||||
authenticationService,
|
|
||||||
loginUserStory,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.appName).isEqualTo("Application Name")
|
|
||||||
assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL)
|
|
||||||
assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - attempt login with error`() = runTest {
|
|
||||||
val authenticationService = FakeMatrixAuthenticationService().apply {
|
|
||||||
givenLoginError(A_THROWABLE)
|
|
||||||
}
|
|
||||||
val loginUserStory = DefaultLoginUserStory()
|
|
||||||
val presenter = WaitListPresenter(
|
|
||||||
LoginFormState.Default,
|
|
||||||
aBuildMeta(),
|
|
||||||
authenticationService,
|
|
||||||
loginUserStory,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
// First usage of AttemptLogin, nothing should happen
|
|
||||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
|
||||||
expectNoEvents()
|
|
||||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
|
||||||
val submitState = awaitItem()
|
|
||||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
|
||||||
val errorState = awaitItem()
|
|
||||||
assertThat(errorState.loginAction).isEqualTo(AsyncData.Failure<SessionId>(A_THROWABLE))
|
|
||||||
// Assert the error can be cleared
|
|
||||||
errorState.eventSink(WaitListEvents.ClearError)
|
|
||||||
val clearedState = awaitItem()
|
|
||||||
assertThat(clearedState.loginAction).isEqualTo(AsyncData.Uninitialized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - attempt login with success`() = runTest {
|
|
||||||
val authenticationService = FakeMatrixAuthenticationService()
|
|
||||||
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
|
|
||||||
val presenter = WaitListPresenter(
|
|
||||||
LoginFormState.Default,
|
|
||||||
aBuildMeta(),
|
|
||||||
authenticationService,
|
|
||||||
loginUserStory,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
|
||||||
val initialState = awaitItem()
|
|
||||||
// First usage of AttemptLogin, nothing should happen
|
|
||||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
|
||||||
expectNoEvents()
|
|
||||||
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
|
||||||
val submitState = awaitItem()
|
|
||||||
assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java)
|
|
||||||
val successState = awaitItem()
|
|
||||||
assertThat(successState.loginAction).isEqualTo(AsyncData.Success(A_USER_ID))
|
|
||||||
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
|
||||||
successState.eventSink.invoke(WaitListEvents.Continue)
|
|
||||||
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,6 +16,7 @@ interface LogoutEntryPoint : FeatureEntryPoint {
|
||||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||||
|
|
||||||
interface NodeBuilder {
|
interface NodeBuilder {
|
||||||
|
fun onSuccessfulLogoutPendingAction(action: () -> Unit): NodeBuilder
|
||||||
fun callback(callback: Callback): NodeBuilder
|
fun callback(callback: Callback): NodeBuilder
|
||||||
fun build(): Node
|
fun build(): Node
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,15 @@ class DefaultLogoutEntryPoint @Inject constructor() : LogoutEntryPoint {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSuccessfulLogoutPendingAction(action: () -> Unit): LogoutEntryPoint.NodeBuilder {
|
||||||
|
plugins += object : LogoutNode.SuccessfulLogoutPendingAction, Plugin {
|
||||||
|
override fun onSuccessfulLogoutPendingAction() {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
override fun build(): Node {
|
override fun build(): Node {
|
||||||
return parentNode.createNode<LogoutNode>(buildContext, plugins)
|
return parentNode.createNode<LogoutNode>(buildContext, plugins)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ class LogoutNode @AssistedInject constructor(
|
||||||
plugins<LogoutEntryPoint.Callback>().forEach { it.onChangeRecoveryKeyClick() }
|
plugins<LogoutEntryPoint.Callback>().forEach { it.onChangeRecoveryKeyClick() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SuccessfulLogoutPendingAction : Plugin {
|
||||||
|
fun onSuccessfulLogoutPendingAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val customOnSuccessfulLogoutPendingAction = plugins<SuccessfulLogoutPendingAction>().firstOrNull()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -41,7 +47,10 @@ class LogoutNode @AssistedInject constructor(
|
||||||
LogoutView(
|
LogoutView(
|
||||||
state = state,
|
state = state,
|
||||||
onChangeRecoveryKeyClick = ::onChangeRecoveryKeyClick,
|
onChangeRecoveryKeyClick = ::onChangeRecoveryKeyClick,
|
||||||
onSuccessLogout = { onSuccessLogout(activity, isDark, it) },
|
onSuccessLogout = {
|
||||||
|
customOnSuccessfulLogoutPendingAction?.onSuccessfulLogoutPendingAction()
|
||||||
|
onSuccessLogout(activity, isDark, it)
|
||||||
|
},
|
||||||
onBackClick = ::navigateUp,
|
onBackClick = ::navigateUp,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -32,7 +34,8 @@ class PinnedEventsTimelineProvider @Inject constructor(
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : TimelineProvider {
|
) : TimelineProvider {
|
||||||
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> = MutableStateFlow(AsyncData.Uninitialized)
|
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> =
|
||||||
|
MutableStateFlow(AsyncData.Uninitialized)
|
||||||
|
|
||||||
override fun activeTimelineFlow(): StateFlow<Timeline?> {
|
override fun activeTimelineFlow(): StateFlow<Timeline?> {
|
||||||
return _timelineStateFlow
|
return _timelineStateFlow
|
||||||
|
|
@ -44,25 +47,46 @@ class PinnedEventsTimelineProvider @Inject constructor(
|
||||||
val timelineStateFlow = _timelineStateFlow
|
val timelineStateFlow = _timelineStateFlow
|
||||||
|
|
||||||
fun launchIn(scope: CoroutineScope) {
|
fun launchIn(scope: CoroutineScope) {
|
||||||
|
_timelineStateFlow.subscriptionCount
|
||||||
|
.map { count -> count > 0 }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { isActive ->
|
||||||
|
if (isActive) {
|
||||||
|
onActive()
|
||||||
|
} else {
|
||||||
|
onInactive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onActive() = coroutineScope {
|
||||||
combine(
|
combine(
|
||||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents),
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents),
|
||||||
networkMonitor.connectivity
|
networkMonitor.connectivity
|
||||||
) {
|
) { isEnabled, _ ->
|
||||||
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
|
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
|
||||||
isEnabled, _ ->
|
|
||||||
isEnabled
|
isEnabled
|
||||||
}
|
}
|
||||||
.onEach { isFeatureEnabled ->
|
.onEach { isFeatureEnabled ->
|
||||||
if (isFeatureEnabled) {
|
if (isFeatureEnabled) {
|
||||||
loadTimelineIfNeeded()
|
loadTimelineIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
_timelineStateFlow.value = AsyncData.Uninitialized
|
resetTimeline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onCompletion {
|
.launchIn(this)
|
||||||
invokeOnTimeline { close() }
|
}
|
||||||
}
|
|
||||||
.launchIn(scope)
|
private suspend fun onInactive() {
|
||||||
|
resetTimeline()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resetTimeline() {
|
||||||
|
invokeOnTimeline {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
_timelineStateFlow.emit(AsyncData.Uninitialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
|
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
|
|
@ -60,6 +59,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||||
private val timelineProvider: PinnedEventsTimelineProvider,
|
private val timelineProvider: PinnedEventsTimelineProvider,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
actionListPresenterFactory: ActionListPresenter.Factory,
|
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||||
|
private val appCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<PinnedMessagesListState> {
|
) : Presenter<PinnedMessagesListState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -100,10 +100,9 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
fun handleEvents(event: PinnedMessagesListEvents) {
|
fun handleEvents(event: PinnedMessagesListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is PinnedMessagesListEvents.HandleAction -> coroutineScope.handleTimelineAction(event.action, event.event)
|
is PinnedMessagesListEvents.HandleAction -> appCoroutineScope.handleTimelineAction(event.action, event.event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,12 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
|
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView
|
||||||
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||||
|
|
@ -40,7 +38,6 @@ fun TimelineItemVirtualRow(
|
||||||
when (virtual.model) {
|
when (virtual.model) {
|
||||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
|
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model)
|
||||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView()
|
|
||||||
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
|
TimelineItemRoomBeginningModel -> TimelineItemRoomBeginningView(roomName = timelineRoomInfo.name)
|
||||||
is TimelineItemLoadingIndicatorModel -> {
|
is TimelineItemLoadingIndicatorModel -> {
|
||||||
TimelineLoadingMoreIndicator(virtual.model.direction)
|
TimelineLoadingMoreIndicator(virtual.model.direction)
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.virtual
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
|
||||||
import io.element.android.features.messages.impl.R
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TimelineEncryptedHistoryBannerView(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
|
|
||||||
.background(ElementTheme.colors.bgInfoSubtle)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
imageVector = CompoundIcons.InfoSolid(),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = ElementTheme.colors.iconInfoPrimary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.screen_room_encrypted_history_banner),
|
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
|
||||||
color = ElementTheme.colors.textInfoPrimary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun EncryptedHistoryBannerViewPreview() = ElementPreview {
|
|
||||||
TimelineEncryptedHistoryBannerView()
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
package io.element.android.features.messages.impl.timeline.factories.virtual
|
package io.element.android.features.messages.impl.timeline.factories.virtual
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||||
|
|
@ -34,7 +33,6 @@ class TimelineItemVirtualFactory @Inject constructor(
|
||||||
return when (val inner = virtual) {
|
return when (val inner = virtual) {
|
||||||
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
|
||||||
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
|
||||||
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
|
|
||||||
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
|
is VirtualTimelineItem.RoomBeginning -> TimelineItemRoomBeginningModel
|
||||||
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
|
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel(
|
||||||
direction = inner.direction,
|
direction = inner.direction,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.virtual
|
|
||||||
|
|
||||||
data object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
|
|
||||||
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
|
|
||||||
}
|
|
||||||
|
|
@ -35,11 +35,14 @@ import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class PinnedMessagesListPresenterTest {
|
class PinnedMessagesListPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state feature disabled`() = runTest {
|
fun `present - initial state feature disabled`() = runTest {
|
||||||
|
|
@ -155,6 +158,7 @@ class PinnedMessagesListPresenterTest {
|
||||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem))
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem))
|
||||||
|
advanceUntilIdle()
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
assert(redactEventLambda)
|
assert(redactEventLambda)
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
|
|
@ -184,9 +188,11 @@ class PinnedMessagesListPresenterTest {
|
||||||
|
|
||||||
pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda
|
pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda
|
||||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda
|
pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda
|
||||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
|
||||||
|
|
@ -221,6 +227,7 @@ class PinnedMessagesListPresenterTest {
|
||||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem))
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem))
|
||||||
|
advanceUntilIdle()
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
assert(onViewInTimelineClickLambda)
|
assert(onViewInTimelineClickLambda)
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
|
|
@ -249,6 +256,7 @@ class PinnedMessagesListPresenterTest {
|
||||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem))
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem))
|
||||||
|
advanceUntilIdle()
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
assert(onShowEventDebugInfoClickLambda)
|
assert(onShowEventDebugInfoClickLambda)
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
|
|
@ -277,6 +285,7 @@ class PinnedMessagesListPresenterTest {
|
||||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem))
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem))
|
||||||
|
advanceUntilIdle()
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
assert(onForwardEventClickLambda)
|
assert(onForwardEventClickLambda)
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
|
|
@ -322,6 +331,7 @@ class PinnedMessagesListPresenterTest {
|
||||||
timelineProvider = timelineProvider,
|
timelineProvider = timelineProvider,
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
actionListPresenterFactory = FakeActionListPresenter.Factory,
|
actionListPresenterFactory = FakeActionListPresenter.Factory,
|
||||||
|
appCoroutineScope = this,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
|
||||||
fun onRoomSettingsClick(roomId: RoomId)
|
fun onRoomSettingsClick(roomId: RoomId)
|
||||||
fun onReportBugClick()
|
fun onReportBugClick()
|
||||||
fun onRoomDirectorySearchClick()
|
fun onRoomDirectorySearchClick()
|
||||||
|
fun onLogoutForNativeSlidingSyncMigrationNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ dependencies {
|
||||||
implementation(projects.libraries.push.api)
|
implementation(projects.libraries.push.api)
|
||||||
implementation(projects.features.invite.api)
|
implementation(projects.features.invite.api)
|
||||||
implementation(projects.features.networkmonitor.api)
|
implementation(projects.features.networkmonitor.api)
|
||||||
|
implementation(projects.features.logout.api)
|
||||||
implementation(projects.features.leaveroom.api)
|
implementation(projects.features.leaveroom.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
@ -75,6 +76,7 @@ dependencies {
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
|
testImplementation(projects.features.logout.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(projects.features.leaveroom.test)
|
testImplementation(projects.features.leaveroom.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
|
||||||
aRoomsContentState(summaries = persistentListOf()),
|
aRoomsContentState(summaries = persistentListOf()),
|
||||||
aSkeletonContentState(),
|
aSkeletonContentState(),
|
||||||
anEmptyContentState(),
|
anEmptyContentState(),
|
||||||
|
aRoomsContentState(securityBannerState = SecurityBannerState.NeedsNativeSlidingSyncMigration),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
sealed interface RoomListEvents {
|
sealed interface RoomListEvents {
|
||||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||||
data object DismissRequestVerificationPrompt : RoomListEvents
|
data object DismissRequestVerificationPrompt : RoomListEvents
|
||||||
data object DismissRecoveryKeyPrompt : RoomListEvents
|
data object DismissBanner : RoomListEvents
|
||||||
data object ToggleSearchResults : RoomListEvents
|
data object ToggleSearchResults : RoomListEvents
|
||||||
data class AcceptInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
data class AcceptInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||||
data class DeclineInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
data class DeclineInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,14 @@ import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.MobileScreen
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteView
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteView
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutEvents
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutView
|
||||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||||
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
|
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
|
||||||
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
|
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -36,6 +39,8 @@ class RoomListNode @AssistedInject constructor(
|
||||||
private val inviteFriendsUseCase: InviteFriendsUseCase,
|
private val inviteFriendsUseCase: InviteFriendsUseCase,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val acceptDeclineInviteView: AcceptDeclineInviteView,
|
private val acceptDeclineInviteView: AcceptDeclineInviteView,
|
||||||
|
private val directLogoutView: DirectLogoutView,
|
||||||
|
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
init {
|
init {
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
|
|
@ -88,6 +93,7 @@ class RoomListNode @AssistedInject constructor(
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
val activity = LocalContext.current as Activity
|
val activity = LocalContext.current as Activity
|
||||||
|
|
||||||
RoomListView(
|
RoomListView(
|
||||||
state = state,
|
state = state,
|
||||||
onRoomClick = this::onRoomClick,
|
onRoomClick = this::onRoomClick,
|
||||||
|
|
@ -98,6 +104,13 @@ class RoomListNode @AssistedInject constructor(
|
||||||
onRoomSettingsClick = this::onRoomSettingsClick,
|
onRoomSettingsClick = this::onRoomSettingsClick,
|
||||||
onMenuActionClick = { onMenuActionClick(activity, it) },
|
onMenuActionClick = { onMenuActionClick(activity, it) },
|
||||||
onRoomDirectorySearchClick = this::onRoomDirectorySearchClick,
|
onRoomDirectorySearchClick = this::onRoomDirectorySearchClick,
|
||||||
|
onMigrateToNativeSlidingSyncClick = {
|
||||||
|
if (state.directLogoutState.canDoDirectSignOut) {
|
||||||
|
state.directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false))
|
||||||
|
} else {
|
||||||
|
plugins<RoomListEntryPoint.Callback>().forEach { it.onLogoutForNativeSlidingSyncMigrationNeeded() }
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
acceptDeclineInviteView.Render(
|
acceptDeclineInviteView.Render(
|
||||||
|
|
@ -107,5 +120,9 @@ class RoomListNode @AssistedInject constructor(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
directLogoutView.Render(state.directLogoutState) {
|
||||||
|
enableNativeSlidingSyncUseCase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.InviteData
|
import io.element.android.features.invite.api.response.InviteData
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutPresenter
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||||
|
|
@ -88,6 +89,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
private val fullScreenIntentPermissionsPresenter: FullScreenIntentPermissionsPresenter,
|
private val fullScreenIntentPermissionsPresenter: FullScreenIntentPermissionsPresenter,
|
||||||
private val notificationCleaner: NotificationCleaner,
|
private val notificationCleaner: NotificationCleaner,
|
||||||
|
private val logoutPresenter: DirectLogoutPresenter,
|
||||||
) : Presenter<RoomListState> {
|
) : Presenter<RoomListState> {
|
||||||
private val encryptionService: EncryptionService = client.encryptionService()
|
private val encryptionService: EncryptionService = client.encryptionService()
|
||||||
private val syncService: SyncService = client.syncService()
|
private val syncService: SyncService = client.syncService()
|
||||||
|
|
@ -115,13 +117,15 @@ class RoomListPresenter @Inject constructor(
|
||||||
|
|
||||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||||
|
|
||||||
|
val directLogoutState = logoutPresenter.present()
|
||||||
|
|
||||||
fun handleEvents(event: RoomListEvents) {
|
fun handleEvents(event: RoomListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
|
is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
|
||||||
updateVisibleRange(event.range)
|
updateVisibleRange(event.range)
|
||||||
}
|
}
|
||||||
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
|
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
|
||||||
RoomListEvents.DismissRecoveryKeyPrompt -> securityBannerDismissed = true
|
RoomListEvents.DismissBanner -> securityBannerDismissed = true
|
||||||
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||||
is RoomListEvents.ShowContextMenu -> {
|
is RoomListEvents.ShowContextMenu -> {
|
||||||
coroutineScope.showContextMenu(event, contextMenu)
|
coroutineScope.showContextMenu(event, contextMenu)
|
||||||
|
|
@ -161,6 +165,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
contentState = contentState,
|
contentState = contentState,
|
||||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||||
|
directLogoutState = directLogoutState,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +173,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
private fun securityBannerState(
|
private fun securityBannerState(
|
||||||
securityBannerDismissed: Boolean,
|
securityBannerDismissed: Boolean,
|
||||||
|
needsSlidingSyncMigration: Boolean,
|
||||||
): State<SecurityBannerState> {
|
): State<SecurityBannerState> {
|
||||||
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
|
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
|
||||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||||
|
|
@ -185,6 +191,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
RecoveryState.ENABLED -> SecurityBannerState.None
|
RecoveryState.ENABLED -> SecurityBannerState.None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
needsSlidingSyncMigration -> SecurityBannerState.NeedsNativeSlidingSyncMigration
|
||||||
else -> SecurityBannerState.None
|
else -> SecurityBannerState.None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -209,11 +216,14 @@ class RoomListPresenter @Inject constructor(
|
||||||
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
|
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val needsSlidingSyncMigration by produceState(false) {
|
||||||
|
value = client.isNativeSlidingSyncSupported() && !client.isUsingNativeSlidingSync()
|
||||||
|
}
|
||||||
return when {
|
return when {
|
||||||
showEmpty -> RoomListContentState.Empty
|
showEmpty -> RoomListContentState.Empty
|
||||||
showSkeleton -> RoomListContentState.Skeleton(count = 16)
|
showSkeleton -> RoomListContentState.Skeleton(count = 16)
|
||||||
else -> {
|
else -> {
|
||||||
val securityBannerState by securityBannerState(securityBannerDismissed)
|
val securityBannerState by securityBannerState(securityBannerDismissed, needsSlidingSyncMigration)
|
||||||
RoomListContentState.Rooms(
|
RoomListContentState.Rooms(
|
||||||
securityBannerState = securityBannerState,
|
securityBannerState = securityBannerState,
|
||||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.roomlist.impl
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||||
|
|
@ -31,6 +32,7 @@ data class RoomListState(
|
||||||
val searchState: RoomListSearchState,
|
val searchState: RoomListSearchState,
|
||||||
val contentState: RoomListContentState,
|
val contentState: RoomListContentState,
|
||||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||||
|
val directLogoutState: DirectLogoutState,
|
||||||
val eventSink: (RoomListEvents) -> Unit,
|
val eventSink: (RoomListEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val displayFilters = contentState is RoomListContentState.Rooms
|
val displayFilters = contentState is RoomListContentState.Rooms
|
||||||
|
|
@ -59,6 +61,7 @@ enum class SecurityBannerState {
|
||||||
None,
|
None,
|
||||||
SetUpRecovery,
|
SetUpRecovery,
|
||||||
RecoveryKeyConfirmation,
|
RecoveryKeyConfirmation,
|
||||||
|
NeedsNativeSlidingSyncMigration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
|
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||||
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
|
|
@ -57,6 +59,7 @@ internal fun aRoomListState(
|
||||||
filtersState: RoomListFiltersState = aRoomListFiltersState(),
|
filtersState: RoomListFiltersState = aRoomListFiltersState(),
|
||||||
contentState: RoomListContentState = aRoomsContentState(),
|
contentState: RoomListContentState = aRoomsContentState(),
|
||||||
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
||||||
|
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||||
eventSink: (RoomListEvents) -> Unit = {}
|
eventSink: (RoomListEvents) -> Unit = {}
|
||||||
) = RoomListState(
|
) = RoomListState(
|
||||||
matrixUser = matrixUser,
|
matrixUser = matrixUser,
|
||||||
|
|
@ -69,6 +72,7 @@ internal fun aRoomListState(
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
contentState = contentState,
|
contentState = contentState,
|
||||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||||
|
directLogoutState = directLogoutState,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ fun RoomListView(
|
||||||
onRoomSettingsClick: (roomId: RoomId) -> Unit,
|
onRoomSettingsClick: (roomId: RoomId) -> Unit,
|
||||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||||
onRoomDirectorySearchClick: () -> Unit,
|
onRoomDirectorySearchClick: () -> Unit,
|
||||||
|
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
acceptDeclineInviteView: @Composable () -> Unit,
|
acceptDeclineInviteView: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -76,6 +77,7 @@ fun RoomListView(
|
||||||
onOpenSettings = onSettingsClick,
|
onOpenSettings = onSettingsClick,
|
||||||
onCreateRoomClick = onCreateRoomClick,
|
onCreateRoomClick = onCreateRoomClick,
|
||||||
onMenuActionClick = onMenuActionClick,
|
onMenuActionClick = onMenuActionClick,
|
||||||
|
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||||
modifier = Modifier.padding(top = topPadding),
|
modifier = Modifier.padding(top = topPadding),
|
||||||
)
|
)
|
||||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||||
|
|
@ -105,6 +107,7 @@ private fun RoomListScaffold(
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onCreateRoomClick: () -> Unit,
|
onCreateRoomClick: () -> Unit,
|
||||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||||
|
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun onRoomClick(room: RoomListRoomSummary) {
|
fun onRoomClick(room: RoomListRoomSummary) {
|
||||||
|
|
@ -140,6 +143,7 @@ private fun RoomListScaffold(
|
||||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||||
onRoomClick = ::onRoomClick,
|
onRoomClick = ::onRoomClick,
|
||||||
onCreateRoomClick = onCreateRoomClick,
|
onCreateRoomClick = onCreateRoomClick,
|
||||||
|
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
|
|
@ -180,5 +184,6 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
|
||||||
onMenuActionClick = {},
|
onMenuActionClick = {},
|
||||||
onRoomDirectorySearchClick = {},
|
onRoomDirectorySearchClick = {},
|
||||||
acceptDeclineInviteView = {},
|
acceptDeclineInviteView = {},
|
||||||
|
onMigrateToNativeSlidingSyncClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.roomlist.impl.components
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import io.element.android.features.roomlist.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun NativeSlidingSyncMigrationBanner(
|
||||||
|
onContinueClick: () -> Unit,
|
||||||
|
onDismissClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
DialogLikeBannerMolecule(
|
||||||
|
modifier = modifier,
|
||||||
|
title = stringResource(R.string.banner_migrate_to_native_sliding_sync_title),
|
||||||
|
content = stringResource(R.string.banner_migrate_to_native_sliding_sync_description),
|
||||||
|
actionText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action),
|
||||||
|
onSubmitClick = onContinueClick,
|
||||||
|
onDismissClick = onDismissClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun NativeSlidingSyncMigrationBannerPreview() = ElementPreview {
|
||||||
|
NativeSlidingSyncMigrationBanner(
|
||||||
|
onContinueClick = {},
|
||||||
|
onDismissClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,7 @@ fun RoomListContentView(
|
||||||
onConfirmRecoveryKeyClick: () -> Unit,
|
onConfirmRecoveryKeyClick: () -> Unit,
|
||||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||||
onCreateRoomClick: () -> Unit,
|
onCreateRoomClick: () -> Unit,
|
||||||
|
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
|
@ -85,6 +86,7 @@ fun RoomListContentView(
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||||
|
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||||
onRoomClick = onRoomClick,
|
onRoomClick = onRoomClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +135,7 @@ private fun RoomsView(
|
||||||
onSetUpRecoveryClick: () -> Unit,
|
onSetUpRecoveryClick: () -> Unit,
|
||||||
onConfirmRecoveryKeyClick: () -> Unit,
|
onConfirmRecoveryKeyClick: () -> Unit,
|
||||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||||
|
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
|
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
|
||||||
|
|
@ -147,6 +150,7 @@ private fun RoomsView(
|
||||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||||
onRoomClick = onRoomClick,
|
onRoomClick = onRoomClick,
|
||||||
|
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +163,7 @@ private fun RoomsViewList(
|
||||||
onSetUpRecoveryClick: () -> Unit,
|
onSetUpRecoveryClick: () -> Unit,
|
||||||
onConfirmRecoveryKeyClick: () -> Unit,
|
onConfirmRecoveryKeyClick: () -> Unit,
|
||||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||||
|
onMigrateToNativeSlidingSyncClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
|
|
@ -185,7 +190,7 @@ private fun RoomsViewList(
|
||||||
item {
|
item {
|
||||||
SetUpRecoveryKeyBanner(
|
SetUpRecoveryKeyBanner(
|
||||||
onContinueClick = onSetUpRecoveryClick,
|
onContinueClick = onSetUpRecoveryClick,
|
||||||
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
|
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +198,15 @@ private fun RoomsViewList(
|
||||||
item {
|
item {
|
||||||
ConfirmRecoveryKeyBanner(
|
ConfirmRecoveryKeyBanner(
|
||||||
onContinueClick = onConfirmRecoveryKeyClick,
|
onContinueClick = onConfirmRecoveryKeyClick,
|
||||||
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
|
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecurityBannerState.NeedsNativeSlidingSyncMigration -> {
|
||||||
|
item {
|
||||||
|
NativeSlidingSyncMigrationBanner(
|
||||||
|
onContinueClick = onMigrateToNativeSlidingSyncClick,
|
||||||
|
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -278,5 +291,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
|
||||||
onConfirmRecoveryKeyClick = {},
|
onConfirmRecoveryKeyClick = {},
|
||||||
onRoomClick = {},
|
onRoomClick = {},
|
||||||
onCreateRoomClick = {},
|
onCreateRoomClick = {},
|
||||||
|
onMigrateToNativeSlidingSyncClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="banner_migrate_to_native_sliding_sync_action">"Log Out & Upgrade"</string>
|
||||||
|
<string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string>
|
||||||
|
<string name="banner_migrate_to_native_sliding_sync_title">"Upgrade available"</string>
|
||||||
<string name="banner_set_up_recovery_content">"Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices."</string>
|
<string name="banner_set_up_recovery_content">"Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices."</string>
|
||||||
<string name="banner_set_up_recovery_title">"Set up recovery"</string>
|
<string name="banner_set_up_recovery_title">"Set up recovery"</string>
|
||||||
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
|
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.roomlist.impl
|
package io.element.android.features.roomlist.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
|
@ -18,6 +19,8 @@ import io.element.android.features.invite.api.response.anAcceptDeclineInviteStat
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
|
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
|
||||||
|
import io.element.android.features.logout.api.direct.DirectLogoutPresenter
|
||||||
|
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||||
|
|
@ -240,7 +243,7 @@ class RoomListPresenterTest {
|
||||||
sessionVerificationService = FakeSessionVerificationService().apply {
|
sessionVerificationService = FakeSessionVerificationService().apply {
|
||||||
givenNeedsSessionVerification(false)
|
givenNeedsSessionVerification(false)
|
||||||
},
|
},
|
||||||
syncService = FakeSyncService(MutableStateFlow(SyncState.Running))
|
syncService = FakeSyncService(MutableStateFlow(SyncState.Running)),
|
||||||
)
|
)
|
||||||
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
|
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
|
||||||
val presenter = createRoomListPresenter(
|
val presenter = createRoomListPresenter(
|
||||||
|
|
@ -268,7 +271,7 @@ class RoomListPresenterTest {
|
||||||
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
|
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
|
||||||
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
|
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
|
||||||
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
|
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
|
||||||
nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt)
|
nextState.eventSink(RoomListEvents.DismissBanner)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
|
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|
@ -644,6 +647,10 @@ class RoomListPresenterTest {
|
||||||
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||||
|
logoutPresenter: DirectLogoutPresenter = object : DirectLogoutPresenter {
|
||||||
|
@Composable
|
||||||
|
override fun present() = aDirectLogoutState()
|
||||||
|
},
|
||||||
) = RoomListPresenter(
|
) = RoomListPresenter(
|
||||||
client = client,
|
client = client,
|
||||||
networkMonitor = networkMonitor,
|
networkMonitor = networkMonitor,
|
||||||
|
|
@ -671,5 +678,6 @@ class RoomListPresenterTest {
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
fullScreenIntentPermissionsPresenter = FakeFullScreenIntentPermissionsPresenter(),
|
fullScreenIntentPermissionsPresenter = FakeFullScreenIntentPermissionsPresenter(),
|
||||||
notificationCleaner = notificationCleaner,
|
notificationCleaner = notificationCleaner,
|
||||||
|
logoutPresenter = logoutPresenter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ class RoomListViewTest {
|
||||||
|
|
||||||
val close = rule.activity.getString(CommonStrings.action_close)
|
val close = rule.activity.getString(CommonStrings.action_close)
|
||||||
rule.onNodeWithContentDescription(close).performClick()
|
rule.onNodeWithContentDescription(close).performClick()
|
||||||
eventsRecorder.assertSingle(RoomListEvents.DismissRecoveryKeyPrompt)
|
eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -86,7 +86,7 @@ class RoomListViewTest {
|
||||||
|
|
||||||
val close = rule.activity.getString(CommonStrings.action_close)
|
val close = rule.activity.getString(CommonStrings.action_close)
|
||||||
rule.onNodeWithContentDescription(close).performClick()
|
rule.onNodeWithContentDescription(close).performClick()
|
||||||
eventsRecorder.assertSingle(RoomListEvents.DismissRecoveryKeyPrompt)
|
eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -232,6 +232,21 @@ class RoomListViewTest {
|
||||||
listOf(RoomListEvents.AcceptInvite(invitedRoom), RoomListEvents.DeclineInvite(invitedRoom)),
|
listOf(RoomListEvents.AcceptInvite(invitedRoom), RoomListEvents.DeclineInvite(invitedRoom)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on logout and migrate calls the migration clicked callback`() {
|
||||||
|
val state = aRoomListState(
|
||||||
|
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.NeedsNativeSlidingSyncMigration),
|
||||||
|
eventSink = {},
|
||||||
|
)
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setRoomListView(
|
||||||
|
state = state,
|
||||||
|
onMigrateToNativeSlidingSyncClick = callback,
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.banner_migrate_to_native_sliding_sync_action)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomListView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomListView(
|
||||||
|
|
@ -244,6 +259,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||||
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
|
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
|
onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onMigrateToNativeSlidingSyncClick: () -> Unit = EnsureNeverCalled()
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
RoomListView(
|
RoomListView(
|
||||||
|
|
@ -256,6 +272,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||||
onRoomSettingsClick = onRoomSettingsClick,
|
onRoomSettingsClick = onRoomSettingsClick,
|
||||||
onMenuActionClick = onMenuActionClick,
|
onMenuActionClick = onMenuActionClick,
|
||||||
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
|
onRoomDirectorySearchClick = onRoomDirectorySearchClick,
|
||||||
|
onMigrateToNativeSlidingSyncClick = onMigrateToNativeSlidingSyncClick,
|
||||||
acceptDeclineInviteView = { },
|
acceptDeclineInviteView = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ fun DialogLikeBannerMolecule(
|
||||||
onSubmitClick: () -> Unit,
|
onSubmitClick: () -> Unit,
|
||||||
onDismissClick: (() -> Unit)?,
|
onDismissClick: (() -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
actionText: String = stringResource(CommonStrings.action_continue),
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -74,7 +75,7 @@ fun DialogLikeBannerMolecule(
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Button(
|
Button(
|
||||||
text = stringResource(CommonStrings.action_continue),
|
text = actionText,
|
||||||
size = ButtonSize.Medium,
|
size = ButtonSize.Medium,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = onSubmitClick,
|
onClick = onSubmitClick,
|
||||||
|
|
|
||||||
|
|
@ -126,4 +126,10 @@ interface MatrixClient : Closeable {
|
||||||
*/
|
*/
|
||||||
suspend fun getUrl(url: String): Result<String>
|
suspend fun getUrl(url: String): Result<String>
|
||||||
suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomPreview>
|
suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomPreview>
|
||||||
|
|
||||||
|
/** Returns `true` if the home server supports native sliding sync. */
|
||||||
|
suspend fun isNativeSlidingSyncSupported(): Boolean
|
||||||
|
|
||||||
|
/** Returns `true` if the current session is using native sliding sync. */
|
||||||
|
fun isUsingNativeSlidingSync(): Boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ sealed interface VirtualTimelineItem {
|
||||||
|
|
||||||
data object ReadMarker : VirtualTimelineItem
|
data object ReadMarker : VirtualTimelineItem
|
||||||
|
|
||||||
data object EncryptedHistoryBanner : VirtualTimelineItem
|
|
||||||
|
|
||||||
data object RoomBeginning : VirtualTimelineItem
|
data object RoomBeginning : VirtualTimelineItem
|
||||||
|
|
||||||
data object LastForwardIndicator : VirtualTimelineItem
|
data object LastForwardIndicator : VirtualTimelineItem
|
||||||
|
|
|
||||||
|
|
@ -87,13 +87,13 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.matrix.rustcomponents.sdk.BackupState
|
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.ClientException
|
import org.matrix.rustcomponents.sdk.ClientException
|
||||||
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
|
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
|
||||||
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
||||||
import org.matrix.rustcomponents.sdk.PowerLevels
|
import org.matrix.rustcomponents.sdk.PowerLevels
|
||||||
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
|
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
|
||||||
|
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
|
||||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -179,7 +179,6 @@ class RustMatrixClient(
|
||||||
systemClock = clock,
|
systemClock = clock,
|
||||||
roomContentForwarder = RoomContentForwarder(innerRoomListService),
|
roomContentForwarder = RoomContentForwarder(innerRoomListService),
|
||||||
roomSyncSubscriber = roomSyncSubscriber,
|
roomSyncSubscriber = roomSyncSubscriber,
|
||||||
isKeyBackupEnabled = { client.encryption().use { it.backupState() == BackupState.ENABLED } },
|
|
||||||
getSessionData = { sessionStore.getSession(sessionId.value)!! },
|
getSessionData = { sessionStore.getSession(sessionId.value)!! },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -530,6 +529,14 @@ class RustMatrixClient(
|
||||||
})
|
})
|
||||||
}.buffer(Channel.UNLIMITED)
|
}.buffer(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
override suspend fun isNativeSlidingSyncSupported(): Boolean {
|
||||||
|
return client.availableSlidingSyncVersions().contains(SlidingSyncVersion.Native)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isUsingNativeSlidingSync(): Boolean {
|
||||||
|
return client.session().slidingSyncVersion == SlidingSyncVersion.Native
|
||||||
|
}
|
||||||
|
|
||||||
internal fun setDelegate(delegate: RustClientSessionDelegate) {
|
internal fun setDelegate(delegate: RustClientSessionDelegate) {
|
||||||
client.setDelegate(delegate)
|
client.setDelegate(delegate)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,6 @@ import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class RustMatrixRoom(
|
class RustMatrixRoom(
|
||||||
override val sessionId: SessionId,
|
override val sessionId: SessionId,
|
||||||
private val isKeyBackupEnabled: Boolean,
|
|
||||||
private val roomListItem: RoomListItem,
|
private val roomListItem: RoomListItem,
|
||||||
private val innerRoom: InnerRoom,
|
private val innerRoom: InnerRoom,
|
||||||
innerTimeline: InnerTimeline,
|
innerTimeline: InnerTimeline,
|
||||||
|
|
@ -652,16 +651,14 @@ class RustMatrixRoom(
|
||||||
): Timeline {
|
): Timeline {
|
||||||
val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$timeline")
|
val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$timeline")
|
||||||
return RustTimeline(
|
return RustTimeline(
|
||||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
|
||||||
mode = mode,
|
mode = mode,
|
||||||
matrixRoom = this,
|
matrixRoom = this,
|
||||||
|
inner = timeline,
|
||||||
systemClock = systemClock,
|
systemClock = systemClock,
|
||||||
coroutineScope = timelineCoroutineScope,
|
coroutineScope = timelineCoroutineScope,
|
||||||
dispatcher = roomDispatcher,
|
dispatcher = roomDispatcher,
|
||||||
lastLoginTimestamp = sessionData.loginTimestamp,
|
|
||||||
onNewSyncedEvent = onNewSyncedEvent,
|
|
||||||
roomContentForwarder = roomContentForwarder,
|
roomContentForwarder = roomContentForwarder,
|
||||||
inner = timeline,
|
onNewSyncedEvent = onNewSyncedEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ class RustRoomFactory(
|
||||||
private val roomListService: RoomListService,
|
private val roomListService: RoomListService,
|
||||||
private val innerRoomListService: InnerRoomListService,
|
private val innerRoomListService: InnerRoomListService,
|
||||||
private val roomSyncSubscriber: RoomSyncSubscriber,
|
private val roomSyncSubscriber: RoomSyncSubscriber,
|
||||||
private val isKeyBackupEnabled: suspend () -> Boolean,
|
|
||||||
private val getSessionData: suspend () -> SessionData,
|
private val getSessionData: suspend () -> SessionData,
|
||||||
) {
|
) {
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
@ -109,7 +108,6 @@ class RustRoomFactory(
|
||||||
val liveTimeline = roomReferences.fullRoom.timeline()
|
val liveTimeline = roomReferences.fullRoom.timeline()
|
||||||
RustMatrixRoom(
|
RustMatrixRoom(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
isKeyBackupEnabled = isKeyBackupEnabled(),
|
|
||||||
roomListItem = roomReferences.roomListItem,
|
roomListItem = roomReferences.roomListItem,
|
||||||
innerRoom = roomReferences.fullRoom,
|
innerRoom = roomReferences.fullRoom,
|
||||||
innerTimeline = liveTimeline,
|
innerTimeline = liveTimeline,
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTim
|
||||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
|
||||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
|
||||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
|
||||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
|
||||||
import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper
|
import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper
|
||||||
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
|
@ -70,7 +69,6 @@ import org.matrix.rustcomponents.sdk.use
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
|
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Date
|
|
||||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||||
|
|
||||||
private const val PAGINATION_SIZE = 50
|
private const val PAGINATION_SIZE = 50
|
||||||
|
|
@ -79,11 +77,9 @@ class RustTimeline(
|
||||||
private val inner: InnerTimeline,
|
private val inner: InnerTimeline,
|
||||||
mode: Timeline.Mode,
|
mode: Timeline.Mode,
|
||||||
systemClock: SystemClock,
|
systemClock: SystemClock,
|
||||||
isKeyBackupEnabled: Boolean,
|
|
||||||
private val matrixRoom: MatrixRoom,
|
private val matrixRoom: MatrixRoom,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
lastLoginTimestamp: Date?,
|
|
||||||
private val roomContentForwarder: RoomContentForwarder,
|
private val roomContentForwarder: RoomContentForwarder,
|
||||||
onNewSyncedEvent: () -> Unit,
|
onNewSyncedEvent: () -> Unit,
|
||||||
) : Timeline {
|
) : Timeline {
|
||||||
|
|
@ -107,12 +103,6 @@ class RustTimeline(
|
||||||
timelineItems = _timelineItems,
|
timelineItems = _timelineItems,
|
||||||
timelineItemFactory = timelineItemMapper,
|
timelineItemFactory = timelineItemMapper,
|
||||||
)
|
)
|
||||||
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
|
||||||
lastLoginTimestamp = lastLoginTimestamp,
|
|
||||||
isRoomEncrypted = matrixRoom.isEncrypted,
|
|
||||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
|
||||||
dispatcher = dispatcher,
|
|
||||||
)
|
|
||||||
private val timelineItemsSubscriber = TimelineItemsSubscriber(
|
private val timelineItemsSubscriber = TimelineItemsSubscriber(
|
||||||
timeline = inner,
|
timeline = inner,
|
||||||
timelineCoroutineScope = coroutineScope,
|
timelineCoroutineScope = coroutineScope,
|
||||||
|
|
@ -219,7 +209,6 @@ class RustTimeline(
|
||||||
) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward, isInit ->
|
) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward, isInit ->
|
||||||
withContext(dispatcher) {
|
withContext(dispatcher) {
|
||||||
timelineItems
|
timelineItems
|
||||||
.process { items -> encryptedHistoryPostProcessor.process(items) }
|
|
||||||
.process { items ->
|
.process { items ->
|
||||||
roomBeginningPostProcessor.process(
|
roomBeginningPostProcessor.process(
|
||||||
items = items,
|
items = items,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
|
||||||
|
|
||||||
internal fun List<MatrixTimelineItem>.hasEncryptionHistoryBanner(): Boolean {
|
|
||||||
val firstItem = firstOrNull()
|
|
||||||
return firstItem is MatrixTimelineItem.Virtual &&
|
|
||||||
firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
|
||||||
}
|
|
||||||
|
|
@ -19,11 +19,10 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
|
||||||
hasMoreToLoadBackward: Boolean,
|
hasMoreToLoadBackward: Boolean,
|
||||||
hasMoreToLoadForward: Boolean,
|
hasMoreToLoadForward: Boolean,
|
||||||
): List<MatrixTimelineItem> {
|
): List<MatrixTimelineItem> {
|
||||||
val shouldAddBackwardLoadingIndicator = hasMoreToLoadBackward && !items.hasEncryptionHistoryBanner()
|
|
||||||
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
|
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
|
||||||
val currentTimestamp = systemClock.epochMillis()
|
val currentTimestamp = systemClock.epochMillis()
|
||||||
return buildList {
|
return buildList {
|
||||||
if (shouldAddBackwardLoadingIndicator) {
|
if (hasMoreToLoadBackward) {
|
||||||
val backwardLoadingIndicator = MatrixTimelineItem.Virtual(
|
val backwardLoadingIndicator = MatrixTimelineItem.Virtual(
|
||||||
uniqueId = UniqueId("BackwardLoadingIndicator"),
|
uniqueId = UniqueId("BackwardLoadingIndicator"),
|
||||||
virtual = VirtualTimelineItem.LoadingIndicator(
|
virtual = VirtualTimelineItem.LoadingIndicator(
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processForRoom(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
private fun processForRoom(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||||
if (items.hasEncryptionHistoryBanner()) return items
|
|
||||||
val roomBeginningItem = createRoomBeginningItem()
|
val roomBeginningItem = createRoomBeginningItem()
|
||||||
return listOf(roomBeginningItem) + items
|
return listOf(roomBeginningItem) + items
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
internal val encryptedHistoryBannerId = UniqueId("EncryptedHistoryBannerId")
|
|
||||||
|
|
||||||
class TimelineEncryptedHistoryPostProcessor(
|
|
||||||
private val dispatcher: CoroutineDispatcher,
|
|
||||||
private val lastLoginTimestamp: Date?,
|
|
||||||
private val isRoomEncrypted: Boolean,
|
|
||||||
private val isKeyBackupEnabled: Boolean,
|
|
||||||
) {
|
|
||||||
suspend fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> = withContext(dispatcher) {
|
|
||||||
Timber.d("Process on Thread=${Thread.currentThread()}")
|
|
||||||
if (!isRoomEncrypted || isKeyBackupEnabled || lastLoginTimestamp == null) return@withContext items
|
|
||||||
replaceWithEncryptionHistoryBannerIfNeeded(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
|
||||||
var lastEncryptedHistoryBannerIndex = -1
|
|
||||||
for ((i, item) in list.withIndex()) {
|
|
||||||
if (isItemEncryptionHistory(item)) {
|
|
||||||
lastEncryptedHistoryBannerIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (lastEncryptedHistoryBannerIndex >= 0) {
|
|
||||||
val sublist = list.drop(lastEncryptedHistoryBannerIndex + 1).toMutableList()
|
|
||||||
sublist.add(0, MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner))
|
|
||||||
sublist
|
|
||||||
} else {
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isItemEncryptionHistory(item: MatrixTimelineItem): Boolean {
|
|
||||||
if ((item as? MatrixTimelineItem.Virtual)?.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val timestamp = (item as? MatrixTimelineItem.Event)?.event?.timestamp ?: return false
|
|
||||||
return timestamp <= lastLoginTimestamp!!.time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||||
|
|
@ -70,16 +69,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `processor will not add beginning of room item if it's not a DM and EncryptedHistoryBanner item is found`() {
|
|
||||||
val timelineItems = listOf(
|
|
||||||
MatrixTimelineItem.Virtual(UniqueId("EncryptedHistoryBanner"), VirtualTimelineItem.EncryptedHistoryBanner),
|
|
||||||
)
|
|
||||||
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
|
|
||||||
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
|
|
||||||
assertThat(processedItems).isEqualTo(timelineItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `processor won't remove items if it's not at the start of the timeline`() {
|
fun `processor won't remove items if it's not at the start of the timeline`() {
|
||||||
val timelineItems = listOf(
|
val timelineItems = listOf(
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
|
||||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Test
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
class TimelineEncryptedHistoryPostProcessorTest {
|
|
||||||
private val defaultLastLoginTimestamp = Date(1_689_061_264L)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given an unencrypted room, nothing is done`() = runTest {
|
|
||||||
val processor = createPostProcessor(isRoomEncrypted = false)
|
|
||||||
val items = listOf(
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
|
||||||
)
|
|
||||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given an encrypted room, and key backup enabled, nothing is done`() = runTest {
|
|
||||||
val processor = createPostProcessor(isKeyBackupEnabled = true)
|
|
||||||
val items = listOf(
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
|
||||||
)
|
|
||||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given a null lastLoginTimestamp, nothing is done`() = runTest {
|
|
||||||
val processor = createPostProcessor(lastLoginTimestamp = null)
|
|
||||||
val items = listOf(
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
|
|
||||||
)
|
|
||||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given an empty list, nothing is done`() = runTest {
|
|
||||||
val processor = createPostProcessor()
|
|
||||||
val items = emptyList<MatrixTimelineItem>()
|
|
||||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given a list with no items before lastLoginTimestamp, nothing is done`() = runTest {
|
|
||||||
val processor = createPostProcessor()
|
|
||||||
val items = listOf(
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
|
||||||
)
|
|
||||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() = runTest {
|
|
||||||
val processor = createPostProcessor()
|
|
||||||
val items = listOf(
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time))
|
|
||||||
)
|
|
||||||
assertThat(processor.process(items))
|
|
||||||
.isEqualTo(listOf(MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() = runTest {
|
|
||||||
val processor = createPostProcessor()
|
|
||||||
val items = listOf(
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1))
|
|
||||||
)
|
|
||||||
assertThat(processor.process(items)).isEqualTo(
|
|
||||||
listOf(MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, then they're replaced`() = runTest {
|
|
||||||
val processor = createPostProcessor()
|
|
||||||
val items = listOf(
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)),
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)),
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)),
|
|
||||||
)
|
|
||||||
assertThat(processor.process(items)).isEqualTo(
|
|
||||||
listOf(
|
|
||||||
MatrixTimelineItem.Virtual(encryptedHistoryBannerId, VirtualTimelineItem.EncryptedHistoryBanner),
|
|
||||||
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun TestScope.createPostProcessor(
|
|
||||||
lastLoginTimestamp: Date? = defaultLastLoginTimestamp,
|
|
||||||
isRoomEncrypted: Boolean = true,
|
|
||||||
isKeyBackupEnabled: Boolean = false,
|
|
||||||
) = TimelineEncryptedHistoryPostProcessor(
|
|
||||||
lastLoginTimestamp = lastLoginTimestamp,
|
|
||||||
isRoomEncrypted = isRoomEncrypted,
|
|
||||||
isKeyBackupEnabled = isKeyBackupEnabled,
|
|
||||||
dispatcher = StandardTestDispatcher(testScheduler)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -77,6 +77,8 @@ class FakeMatrixClient(
|
||||||
private val clearCacheLambda: () -> Unit = { lambdaError() },
|
private val clearCacheLambda: () -> Unit = { lambdaError() },
|
||||||
private val userIdServerNameLambda: () -> String = { lambdaError() },
|
private val userIdServerNameLambda: () -> String = { lambdaError() },
|
||||||
private val getUrlLambda: (String) -> Result<String> = { lambdaError() },
|
private val getUrlLambda: (String) -> Result<String> = { lambdaError() },
|
||||||
|
var isNativeSlidingSyncSupportedLambda: suspend () -> Boolean = { true },
|
||||||
|
var isUsingNativeSlidingSyncLambda: () -> Boolean = { true },
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
var setDisplayNameCalled: Boolean = false
|
var setDisplayNameCalled: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
@ -316,4 +318,12 @@ class FakeMatrixClient(
|
||||||
override suspend fun getUrl(url: String): Result<String> {
|
override suspend fun getUrl(url: String): Result<String> {
|
||||||
return getUrlLambda(url)
|
return getUrlLambda(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun isNativeSlidingSyncSupported(): Boolean {
|
||||||
|
return isNativeSlidingSyncSupportedLambda()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isUsingNativeSlidingSync(): Boolean {
|
||||||
|
return isUsingNativeSlidingSyncLambda()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,8 @@ dependencies {
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.preferences.api.store
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class EnableNativeSlidingSyncUseCase @Inject constructor(
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
operator fun invoke() {
|
||||||
|
appCoroutineScope.launch {
|
||||||
|
appPreferencesStore.setSimplifiedSlidingSyncEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.preferences.api.store
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class EnableNativeSlidingSyncUseCaseTest {
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `ensure that the use case sets the simplified sliding sync enabled flag`() = runTest {
|
||||||
|
val preferencesStore = InMemoryAppPreferencesStore()
|
||||||
|
val useCase = EnableNativeSlidingSyncUseCase(preferencesStore, this)
|
||||||
|
assertThat(preferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
|
||||||
|
|
||||||
|
useCase()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(preferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,7 +47,7 @@ private const val versionMinor = 5
|
||||||
// Note: even values are reserved for regular release, odd values for hotfix release.
|
// Note: even values are reserved for regular release, odd values for hotfix release.
|
||||||
// When creating a hotfix, you should decrease the value, since the current value
|
// When creating a hotfix, you should decrease the value, since the current value
|
||||||
// is the value for the next regular release.
|
// is the value for the next regular release.
|
||||||
private const val versionPatch = 3
|
private const val versionPatch = 4
|
||||||
|
|
||||||
object Versions {
|
object Versions {
|
||||||
val versionCode = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch
|
val versionCode = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ dependencies {
|
||||||
implementation(projects.features.roomlist.impl)
|
implementation(projects.features.roomlist.impl)
|
||||||
implementation(projects.features.leaveroom.impl)
|
implementation(projects.features.leaveroom.impl)
|
||||||
implementation(projects.features.login.impl)
|
implementation(projects.features.login.impl)
|
||||||
|
implementation(projects.features.logout.impl)
|
||||||
implementation(projects.features.networkmonitor.impl)
|
implementation(projects.features.networkmonitor.impl)
|
||||||
implementation(projects.services.toolbox.impl)
|
implementation(projects.services.toolbox.impl)
|
||||||
implementation(projects.libraries.featureflag.impl)
|
implementation(projects.libraries.featureflag.impl)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
onWaitListError = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
|
||||||
import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter
|
import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter
|
||||||
import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
|
import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
|
||||||
import io.element.android.features.leaveroom.impl.DefaultLeaveRoomPresenter
|
import io.element.android.features.leaveroom.impl.DefaultLeaveRoomPresenter
|
||||||
|
import io.element.android.features.logout.impl.direct.DefaultDirectLogoutPresenter
|
||||||
import io.element.android.features.networkmonitor.impl.DefaultNetworkMonitor
|
import io.element.android.features.networkmonitor.impl.DefaultNetworkMonitor
|
||||||
import io.element.android.features.roomlist.impl.RoomListPresenter
|
import io.element.android.features.roomlist.impl.RoomListPresenter
|
||||||
import io.element.android.features.roomlist.impl.RoomListView
|
import io.element.android.features.roomlist.impl.RoomListView
|
||||||
|
|
@ -144,6 +145,7 @@ class RoomListScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
notificationCleaner = FakeNotificationCleaner(),
|
notificationCleaner = FakeNotificationCleaner(),
|
||||||
|
logoutPresenter = DefaultDirectLogoutPresenter(matrixClient, encryptionService),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -172,7 +174,8 @@ class RoomListScreen(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
acceptDeclineInviteView = {
|
acceptDeclineInviteView = {
|
||||||
AcceptDeclineInviteView(state = state.acceptDeclineInviteState, onAcceptInvite = {}, onDeclineInvite = {})
|
AcceptDeclineInviteView(state = state.acceptDeclineInviteState, onAcceptInvite = {}, onDeclineInvite = {})
|
||||||
}
|
},
|
||||||
|
onMigrateToNativeSlidingSyncClick = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:b2c858bc7e2beecc0d4c92df0b4ac61e1e3a975a072e0e75cfa1da6aaa32142c
|
|
||||||
size 140926
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8524bdd37ac6eb784d82041b572d9cf3bb69cf3de318ba1d8abc45e9a239dad1
|
|
||||||
size 141726
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:a4fd2455de9a763e582e4977e36686c714dfb380737fc6193b4eab9ef8b64de9
|
|
||||||
size 58641
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:b2c858bc7e2beecc0d4c92df0b4ac61e1e3a975a072e0e75cfa1da6aaa32142c
|
|
||||||
size 140926
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:f7880a67dd818c44b519be634a4a33a4a0efc842f9ad5e4879f16acb7c8b1f5f
|
|
||||||
size 122622
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:fa9162336cb7d46a2517f4b4149a9f8389e69e410e3896cb6b973fc16a940adf
|
|
||||||
size 138058
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ef852707489db29300b4bf8259a5d71d3c82ce26bcea3ddb00d037b1ba379423
|
|
||||||
size 138901
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5946208785bcc7eff5e76048cd467e9bde6f4ff5874ef2dd2562d5bbb0844f93
|
|
||||||
size 59177
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:fa9162336cb7d46a2517f4b4149a9f8389e69e410e3896cb6b973fc16a940adf
|
|
||||||
size 138058
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:3b4fe8418f426d9953189908053407550ad6d83d45a548253d75507321f686c9
|
|
||||||
size 120721
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:3d81ad9f5a5983653d9266a2500b2c71dc7ca231d52a62d1c1f1b091849ede32
|
|
||||||
size 165501
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:1cb4f2cbdc2b7fca643a9debff50206d4aa9321894a86149d6c815fd4db7b593
|
|
||||||
size 166084
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:3b942ce79745a26aa2d4227c35aaad442b35c2f768915e3d9fabf45a04cd1d82
|
|
||||||
size 61267
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:3d81ad9f5a5983653d9266a2500b2c71dc7ca231d52a62d1c1f1b091849ede32
|
|
||||||
size 165501
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:aa47e394a5af6cad06496a5d36fa81b2e7d76dd9eaec45c3914f1ffce45f9f91
|
|
||||||
size 146617
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:47c6681e3cf56d3229953b7c34b6f1d531c492d23f4dffe77e2a12fbdbf44261
|
|
||||||
size 11892
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:985b87b568e34b16d132a5cde5781e99edc81be4ef53c5b6e67ca75472266f99
|
|
||||||
size 11436
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:45f8b8767d1a54afde878f1c853a7fd6dd5823d04db3b75a7c18c5d6fc742d8e
|
||||||
|
size 36110
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6849a6c73341edc3f37fd6ef968c07e63277d18f833028386012fa2a438ade7e
|
||||||
|
size 34946
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f4ee87f2ae823b3d026b70d6b91463ba6aa3ad7e454b915c0eba3901b219169b
|
||||||
|
size 72188
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:863e7f66501f357728aa048560dc59fccd63bff8fc4b648296d82db119b0137d
|
||||||
|
size 71031
|
||||||
|
|
@ -139,6 +139,7 @@
|
||||||
"session_verification_banner_.*",
|
"session_verification_banner_.*",
|
||||||
"confirm_recovery_key_banner_.*",
|
"confirm_recovery_key_banner_.*",
|
||||||
"banner\\.set_up_recovery\\..*",
|
"banner\\.set_up_recovery\\..*",
|
||||||
|
"banner\\.migrate_to_native_sliding_sync\\..*",
|
||||||
"full_screen_intent_banner_.*",
|
"full_screen_intent_banner_.*",
|
||||||
"screen_migration_.*",
|
"screen_migration_.*",
|
||||||
"screen_invites_.*"
|
"screen_invites_.*"
|
||||||
|
|
|
||||||
|
|
@ -177,9 +177,8 @@ printf "Committing...\n"
|
||||||
git commit -a -m 'version++'
|
git commit -a -m 'version++'
|
||||||
|
|
||||||
printf "\n================================================================================\n"
|
printf "\n================================================================================\n"
|
||||||
printf "Wait for the GitHub action https://github.com/element-hq/element-x-android/actions/workflows/release.yml?query=branch%%3Amain to build the 'main' branch.\n"
|
printf "The GitHub action https://github.com/element-hq/element-x-android/actions/workflows/release.yml?query=branch%%3Amain should have start a new run.\n"
|
||||||
printf "Please enter the url of the github action (!!! WARNING: NOT THE URL OF THE ARTIFACT ANYMORE !!!)\n"
|
read -p "Please enter the url of the run, no need to wait for it to complete (example: https://github.com/element-hq/element-x-android/actions/runs/9065756777): " runUrl
|
||||||
read -p "For instance https://github.com/element-hq/element-x-android/actions/runs/9065756777: " runUrl
|
|
||||||
|
|
||||||
targetPath="./tmp/Element/${version}"
|
targetPath="./tmp/Element/${version}"
|
||||||
|
|
||||||
|
|
@ -270,7 +269,7 @@ printf "File app-fdroid-x86_64-release-signed.apk:\n"
|
||||||
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk | grep package
|
"${buildToolsPath}"/aapt dump badging "${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk | grep package
|
||||||
|
|
||||||
printf "\n"
|
printf "\n"
|
||||||
read -p "Does it look correct? Press enter when it's done."
|
read -p "Does it look correct? Press enter when it's done. "
|
||||||
|
|
||||||
printf "\n================================================================================\n"
|
printf "\n================================================================================\n"
|
||||||
printf "The APKs in ${fdroidTargetPath} have been signed!\n"
|
printf "The APKs in ${fdroidTargetPath} have been signed!\n"
|
||||||
|
|
@ -363,7 +362,7 @@ read -p ". Press enter to continue. "
|
||||||
printf "\n================================================================================\n"
|
printf "\n================================================================================\n"
|
||||||
printf "Update the project release notes:\n\n"
|
printf "Update the project release notes:\n\n"
|
||||||
|
|
||||||
read -p "Copy the content of the release note generated by GitHub to the file CHANGES.md and press enter to commit the change. \n"
|
read -p "Copy the content of the release note generated by GitHub to the file CHANGES.md and press enter to commit the change. "
|
||||||
|
|
||||||
printf "\n================================================================================\n"
|
printf "\n================================================================================\n"
|
||||||
printf "Committing...\n"
|
printf "Committing...\n"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue