diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 88f0741ebe..f7d8540c4e 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.tests.testutils) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 89800b1c82..6be25d5bc2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -42,6 +42,7 @@ import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView +import io.element.android.appnav.signedout.SignedOutNode import io.element.android.features.login.api.oidc.OidcAction import io.element.android.features.login.api.oidc.OidcActionFlow import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint @@ -98,21 +99,22 @@ class RootFlowNode @AssistedInject constructor( .distinctUntilChanged() .onEach { navState -> Timber.v("navState=$navState") - when(navState.loggedInState) { + when (navState.loggedInState) { is LoggedInState.LoggedIn -> { - if(navState.loggedInState.isTokenValid) { + if (navState.loggedInState.isTokenValid) { tryToRestoreLatestSession( onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, onFailure = { switchToNotLoggedInFlow() } ) } else { - switchToSignedOutFlow() + switchToSignedOutFlow(SessionId((navState.loggedInState.sessionId))) } } - LoggedInState.NotLoggedIn -> { - switchToNotLoggedInFlow() + LoggedInState.NotLoggedIn -> { + switchToNotLoggedInFlow() } } + } .launchIn(lifecycleScope) } @@ -125,8 +127,8 @@ class RootFlowNode @AssistedInject constructor( backstack.safeRoot(NavTarget.NotLoggedInFlow) } - private fun switchToSignedOutFlow() { - backstack.safeRoot(NavTarget.SignedOutFlow) + private fun switchToSignedOutFlow(sessionId: SessionId) { + backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) } private suspend fun restoreSessionIfNeeded( @@ -191,7 +193,9 @@ class RootFlowNode @AssistedInject constructor( ) : NavTarget @Parcelize - data object SignedOutFlow : NavTarget + data class SignedOutFlow( + val sessionId: SessionId + ) : NavTarget @Parcelize data object BugReport : NavTarget @@ -212,7 +216,10 @@ class RootFlowNode @AssistedInject constructor( createNode(buildContext, plugins = listOf(inputs, callback)) } NavTarget.NotLoggedInFlow -> createNode(buildContext) - NavTarget.SignedOutFlow -> createNode(buildContext) + is NavTarget.SignedOutFlow -> { + val inputs = SignedOutNode.Inputs(navTarget.sessionId) + createNode(buildContext, listOf(inputs)) + } NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.BugReport -> { val callback = object : BugReportEntryPoint.Callback { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutEvents.kt new file mode 100644 index 0000000000..3c3c61696b --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.signedout + +sealed interface SignedOutEvents { + data object SignInAgain : SignedOutEvents +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutNode.kt new file mode 100644 index 0000000000..25917daab5 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.signedout + +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 dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.SessionId + +@ContributesNode(AppScope::class) +class SignedOutNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SignedOutPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val sessionId: SessionId, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.sessionId.value) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SignedOutView( + state = state, + modifier = modifier + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutPresenter.kt new file mode 100644 index 0000000000..1292ad0496 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutPresenter.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.signedout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +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.libraries.architecture.Presenter +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.launch + +class SignedOutPresenter @AssistedInject constructor( + @Assisted private val sessionId: String, /* Cannot inject SessionId */ + private val sessionStore: SessionStore, +) : Presenter { + + @AssistedFactory + interface Factory { + fun create(sessionId: String): SignedOutPresenter + } + + @Composable + override fun present(): SignedOutState { + val sessions by sessionStore.sessionsFlow().collectAsState(initial = emptyList()) + val signedOutSession by remember { + derivedStateOf { sessions.firstOrNull { it.userId == sessionId } } + } + val coroutineScope = rememberCoroutineScope() + + fun handleEvents(event: SignedOutEvents) { + when (event) { + SignedOutEvents.SignInAgain -> coroutineScope.launch { + sessionStore.removeSession(sessionId) + } + } + } + + return SignedOutState( + signedOutSession = signedOutSession, + eventSink = ::handleEvents + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutState.kt b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutState.kt new file mode 100644 index 0000000000..f181c537ba --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.signedout + +import io.element.android.libraries.sessionstorage.api.SessionData + +// Do not use default value, so no member get forgotten in the presenters. +data class SignedOutState( + val signedOutSession: SessionData?, + val eventSink: (SignedOutEvents) -> Unit, +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutStateProvider.kt new file mode 100644 index 0000000000..b7a81cf86b --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutStateProvider.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.signedout + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData + +open class SignedOutStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSignedOutState(), + // Add other states here + ) +} + +fun aSignedOutState() = SignedOutState( + eventSink = {}, + signedOutSession = aSessionData() +) + +fun aSessionData( + sessionId: SessionId = SessionId("@alice:server.org"), + isTokenValid: Boolean = false, +): SessionData { + return SessionData( + userId = sessionId.value, + deviceId = "aDeviceId", + accessToken = "anAccessToken", + refreshToken = "aRefreshToken", + homeserverUrl = "aHomeserverUrl", + oidcData = null, + slidingSyncProxy = null, + loginTimestamp = null, + isTokenValid = isTokenValid, + loginType = LoginType.UNKNOWN, + ) +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutView.kt b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutView.kt new file mode 100644 index 0000000000..7c31e7713b --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/signedout/SignedOutView.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.signedout + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +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.Icon +import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.theme.ElementTheme +import kotlinx.collections.immutable.persistentListOf + +// TODO i18n, when wording has been approved. +@Composable +fun SignedOutView( + state: SignedOutState, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = { state.eventSink(SignedOutEvents.SignInAgain) }) + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + header = { SignedOutHeader() }, + content = { SignedOutContent() }, + footer = { + SignedOutFooter( + onSignInAgain = { state.eventSink(SignedOutEvents.SignInAgain) }, + ) + } + ) +} + +@Composable +fun SignedOutHeader() { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), + title = "You’re signed out", + subTitle = "It can be due to various reasons:", + iconImageVector = Icons.Filled.AccountCircle + ) +} + +@Composable +private fun SignedOutContent( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = BiasAlignment( + horizontalBias = 0f, + verticalBias = -0.4f + ) + ) { + InfoListOrganism( + items = persistentListOf( + InfoListItem( + message = "You’ve changed your password on another session.", + iconComposable = { CheckIcon() }, + ), + InfoListItem( + message = "You have deleted this session from another session.", + iconComposable = { CheckIcon() }, + ), + InfoListItem( + message = "The administrator of your server has invalidated your access for security reason.", + iconComposable = { CheckIcon() }, + ), + ), + textStyle = ElementTheme.typography.fontBodyMdMedium, + iconTint = ElementTheme.colors.textPrimary, + backgroundColor = ElementTheme.colors.temporaryColorBgSpecial + ) + } +} + +@Composable +private fun CheckIcon(modifier: Modifier = Modifier) { + Icon( + modifier = modifier + .size(20.dp) + .background(color = MaterialTheme.colorScheme.background, shape = CircleShape) + .padding(2.dp), + resourceId = CommonDrawables.ic_compound_check, + contentDescription = null, + tint = ElementTheme.colors.textActionAccent, + ) +} + +@Composable +private fun SignedOutFooter( + modifier: Modifier = Modifier, + onSignInAgain: () -> Unit, +) { + ButtonColumnMolecule( + modifier = modifier, + ) { + Button( + text = "Sign in again", + onClick = onSignInAgain, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@PreviewsDayNight +@Composable +fun SignedOutViewPreview( + @PreviewParameter(SignedOutStateProvider::class) state: SignedOutState, +) = ElementPreview { + SignedOutView( + state = state, + ) +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/signedout/SignedOutPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/signedout/SignedOutPresenterTest.kt new file mode 100644 index 0000000000..529123250c --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/signedout/SignedOutPresenterTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav.signedout + +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.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SignedOutPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val aSessionData = aSessionData() + val sessionStore = InMemorySessionStore().apply { + storeData(aSessionData) + } + val presenter = createPresenter(sessionStore = sessionStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + } + } + + @Test + fun `present - sign in again`() = runTest { + val aSessionData = aSessionData() + val sessionStore = InMemorySessionStore().apply { + storeData(aSessionData) + } + val presenter = createPresenter(sessionStore = sessionStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + assertThat(sessionStore.getAllSessions()).isNotEmpty() + initialState.eventSink(SignedOutEvents.SignInAgain) + assertThat(awaitItem().signedOutSession).isNull() + assertThat(sessionStore.getAllSessions()).isEmpty() + } + } + + private fun createPresenter( + sessionId: SessionId = A_SESSION_ID, + sessionStore: SessionStore = InMemorySessionStore(), + ): SignedOutPresenter { + return SignedOutPresenter( + sessionId = sessionId.value, + sessionStore = sessionStore, + ) + } +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoggedInState.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoggedInState.kt index 5cee58a392..ddf7ae5e8a 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoggedInState.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoggedInState.kt @@ -18,5 +18,8 @@ package io.element.android.libraries.sessionstorage.api sealed interface LoggedInState { data object NotLoggedIn : LoggedInState - data class LoggedIn(val isTokenValid: Boolean) : LoggedInState + data class LoggedIn( + val sessionId: String, + val isTokenValid: Boolean, + ) : LoggedInState } diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt index abbf65a3bd..4b76e82e8b 100644 --- a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt +++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt @@ -32,7 +32,10 @@ class InMemorySessionStore : SessionStore { if (it == null) { LoggedInState.NotLoggedIn } else { - LoggedInState.LoggedIn(it.isTokenValid) + LoggedInState.LoggedIn( + sessionId = it.userId, + isTokenValid = it.isTokenValid, + ) } } } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 4d6b035ee4..1592e7646e 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -44,7 +44,10 @@ class DatabaseSessionStore @Inject constructor( if (it == null) { LoggedInState.NotLoggedIn } else { - LoggedInState.LoggedIn((it.isTokenValid ?: 1) == 1L) + LoggedInState.LoggedIn( + sessionId = it.userId, + isTokenValid = (it.isTokenValid ?: 1) == 1L + ) } } }