Add SignedOutNode, to handle session behind deleted from outside (no support for soft-logout)

This commit is contained in:
Benoit Marty
2023-10-09 19:46:42 +02:00
committed by Benoit Marty
parent 0b3fcecbed
commit 5faf706264
12 changed files with 479 additions and 12 deletions

View File

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

View File

@@ -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<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext)
NavTarget.SignedOutFlow -> createNode<SignedOutFlowNode>(buildContext)
is NavTarget.SignedOutFlow -> {
val inputs = SignedOutNode.Inputs(navTarget.sessionId)
createNode<SignedOutNode>(buildContext, listOf(inputs))
}
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback {

View File

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

View File

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

View File

@@ -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<SignedOutState> {
@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
)
}
}

View File

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

View File

@@ -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<SignedOutState> {
override val values: Sequence<SignedOutState>
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,
)
}

View File

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

View File

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

View File

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

View File

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

View File

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