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 94f344be7e..df98998ba5 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -45,6 +45,7 @@ import io.element.android.appnav.root.RootView 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 +import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode @@ -54,6 +55,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.LoggedInState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -69,6 +71,7 @@ class RootFlowNode @AssistedInject constructor( private val matrixClientsHolder: MatrixClientsHolder, private val presenter: RootPresenter, private val bugReportEntryPoint: BugReportEntryPoint, + private val signedOutEntryPoint: SignedOutEntryPoint, private val intentResolver: IntentResolver, private val oidcActionFlow: OidcActionFlow, ) : BackstackNode( @@ -97,13 +100,20 @@ class RootFlowNode @AssistedInject constructor( .distinctUntilChanged() .onEach { navState -> Timber.v("navState=$navState") - if (navState.isLoggedIn) { - tryToRestoreLatestSession( - onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, - onFailure = { switchToNotLoggedInFlow() } - ) - } else { - switchToNotLoggedInFlow() + when (navState.loggedInState) { + is LoggedInState.LoggedIn -> { + if (navState.loggedInState.isTokenValid) { + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { switchToNotLoggedInFlow() } + ) + } else { + switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) + } + } + LoggedInState.NotLoggedIn -> { + switchToNotLoggedInFlow() + } } } .launchIn(lifecycleScope) @@ -118,6 +128,10 @@ class RootFlowNode @AssistedInject constructor( backstack.safeRoot(NavTarget.NotLoggedInFlow) } + private fun switchToSignedOutFlow(sessionId: SessionId) { + backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) + } + private suspend fun restoreSessionIfNeeded( sessionId: SessionId, onFailure: () -> Unit = {}, @@ -179,6 +193,11 @@ class RootFlowNode @AssistedInject constructor( val navId: Int ) : NavTarget + @Parcelize + data class SignedOutFlow( + val sessionId: SessionId + ) : NavTarget + @Parcelize data object BugReport : NavTarget } @@ -198,6 +217,15 @@ class RootFlowNode @AssistedInject constructor( createNode(buildContext, plugins = listOf(inputs, callback)) } NavTarget.NotLoggedInFlow -> createNode(buildContext) + is NavTarget.SignedOutFlow -> { + signedOutEntryPoint.nodeBuilder(this, buildContext) + .params( + SignedOutEntryPoint.Params( + sessionId = navTarget.sessionId + ) + ) + .build() + } NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.BugReport -> { val callback = object : BugReportEntryPoint.Callback { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt index ed3ac15972..09f9767f62 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt @@ -16,6 +16,8 @@ package io.element.android.appnav.root +import io.element.android.libraries.sessionstorage.api.LoggedInState + /** * [RootNavState] produced by [RootNavStateFlowFactory]. */ @@ -26,7 +28,7 @@ data class RootNavState( */ val cacheIndex: Int, /** - * true if we are currently loggedIn. + * LoggedInState. */ - val isLoggedIn: Boolean + val loggedInState: LoggedInState, ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 0e8d93b0c9..e6a46f5950 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -22,9 +22,9 @@ import io.element.android.appnav.di.MatrixClientsHolder import io.element.android.features.login.api.LoginUserStory import io.element.android.features.preferences.api.CacheService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.sessionstorage.api.LoggedInState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import javax.inject.Inject @@ -47,9 +47,14 @@ class RootNavStateFlowFactory @Inject constructor( fun create(savedStateMap: SavedStateMap?): Flow { return combine( cacheIndexFlow(savedStateMap), - isUserLoggedInFlow(), - ) { cacheIndex, isLoggedIn -> - RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn) + authenticationService.loggedInStateFlow(), + loginUserStory.loginFlowIsDone, + ) { cacheIndex, loggedInState, loginFlowIsDone -> + if (loginFlowIsDone) { + RootNavState(cacheIndex = cacheIndex, loggedInState = loggedInState) + } else { + RootNavState(cacheIndex = cacheIndex, loggedInState = LoggedInState.NotLoggedIn) + } } } @@ -72,16 +77,6 @@ class RootNavStateFlowFactory @Inject constructor( } } - private fun isUserLoggedInFlow(): Flow { - return combine( - authenticationService.isLoggedIn(), - loginUserStory.loginFlowIsDone - ) { isLoggedIn, loginFlowIsDone -> - isLoggedIn && loginFlowIsDone - } - .distinctUntilChanged() - } - /** * @return a flow of integer that increments the value by one each time a new element is emitted upstream. */ diff --git a/changelog.d/1520.feature b/changelog.d/1520.feature new file mode 100644 index 0000000000..eaf99776d6 --- /dev/null +++ b/changelog.d/1520.feature @@ -0,0 +1 @@ +Improve deleted session behavior. diff --git a/features/signedout/api/build.gradle.kts b/features/signedout/api/build.gradle.kts new file mode 100644 index 0000000000..d2815315f3 --- /dev/null +++ b/features/signedout/api/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.signedout.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/signedout/api/src/main/kotlin/io/element/android/features/signedout/api/SignedOutEntryPoint.kt b/features/signedout/api/src/main/kotlin/io/element/android/features/signedout/api/SignedOutEntryPoint.kt new file mode 100644 index 0000000000..7a156998d1 --- /dev/null +++ b/features/signedout/api/src/main/kotlin/io/element/android/features/signedout/api/SignedOutEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * 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.features.signedout.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.SessionId + +interface SignedOutEntryPoint : FeatureEntryPoint { + + data class Params( + val sessionId: SessionId, + ) + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun build(): Node + } +} + diff --git a/features/signedout/impl/build.gradle.kts b/features/signedout/impl/build.gradle.kts new file mode 100644 index 0000000000..0255ac3ca1 --- /dev/null +++ b/features/signedout/impl/build.gradle.kts @@ -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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.signedout.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.signedout.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.implMemory) + testImplementation(projects.tests.testutils) + + ksp(libs.showkase.processor) +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPoint.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPoint.kt new file mode 100644 index 0000000000..59b19f041e --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * 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.features.signedout.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultSignedOutEntryPoint @Inject constructor() : SignedOutEntryPoint { + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SignedOutEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : SignedOutEntryPoint.NodeBuilder { + + override fun params(params: SignedOutEntryPoint.Params): SignedOutEntryPoint.NodeBuilder { + plugins += SignedOutNode.Inputs(params.sessionId) + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvents.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvents.kt new file mode 100644 index 0000000000..a2057226a4 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/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.features.signedout.impl + +sealed interface SignedOutEvents { + data object SignInAgain : SignedOutEvents +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutNode.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutNode.kt new file mode 100644 index 0000000000..381daa7278 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/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.features.signedout.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import 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/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt new file mode 100644 index 0000000000..a93d22253d --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt @@ -0,0 +1,66 @@ +/* + * 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.features.signedout.impl + +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.core.meta.BuildMeta +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, + private val buildMeta: BuildMeta, +) : 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( + appName = buildMeta.applicationName, + signedOutSession = signedOutSession, + eventSink = ::handleEvents + ) + } +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt new file mode 100644 index 0000000000..eff0ab6d5b --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt @@ -0,0 +1,26 @@ +/* + * 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.features.signedout.impl + +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 appName: String, + val signedOutSession: SessionData?, + val eventSink: (SignedOutEvents) -> Unit, +) diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt new file mode 100644 index 0000000000..0182e87cf3 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.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.features.signedout.impl + +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( + appName = "AppName", + signedOutSession = aSessionData(), + eventSink = {}, +) + +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/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt new file mode 100644 index 0000000000..63845ead90 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/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.features.signedout.impl + +import androidx.activity.compose.BackHandler +import androidx.annotation.DrawableRes +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.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.runtime.Composable +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.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.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun SignedOutView( + state: SignedOutState, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = { state.eventSink(SignedOutEvents.SignInAgain) }) + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + header = { SignedOutHeader(state) }, + content = { SignedOutContent() }, + footer = { + SignedOutFooter( + onSignInAgain = { state.eventSink(SignedOutEvents.SignInAgain) }, + ) + } + ) +} + +@Composable +private fun SignedOutHeader(state: SignedOutState) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), + title = stringResource(id = R.string.screen_signed_out_title), + subTitle = stringResource(id = R.string.screen_signed_out_subtitle, state.appName), + iconImageVector = Icons.Filled.AccountCircle, + iconTint = ElementTheme.colors.iconSecondary, + ) +} + +@Composable +private fun SignedOutContent( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = BiasAlignment( + horizontalBias = 0f, + verticalBias = -0.4f + ) + ) { + InfoListOrganism( + items = persistentListOf( + InfoListItem( + message = stringResource(id = R.string.screen_signed_out_reason_1), + iconComposable = { Icon(R.drawable.ic_lock_outline) }, + ), + InfoListItem( + message = stringResource(id = R.string.screen_signed_out_reason_2), + iconComposable = { Icon(R.drawable.ic_devices) }, + ), + InfoListItem( + message = stringResource(id = R.string.screen_signed_out_reason_3), + iconComposable = { Icon(R.drawable.ic_do_disturb_alt) }, + ), + ), + textStyle = ElementTheme.typography.fontBodyMdMedium, + iconTint = ElementTheme.colors.textPrimary, + backgroundColor = ElementTheme.colors.temporaryColorBgSpecial + ) + } +} + +@Composable +private fun Icon( + @DrawableRes iconResourceId: Int, + modifier: Modifier = Modifier, +) { + Icon( + modifier = modifier + .size(20.dp), + resourceId = iconResourceId, + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) +} + +@Composable +private fun SignedOutFooter( + modifier: Modifier = Modifier, + onSignInAgain: () -> Unit, +) { + ButtonColumnMolecule( + modifier = modifier, + ) { + Button( + text = stringResource(id = CommonStrings.action_sign_in_again), + onClick = onSignInAgain, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SignedOutViewPreview( + @PreviewParameter(SignedOutStateProvider::class) state: SignedOutState, +) = ElementPreview { + SignedOutView( + state = state, + ) +} diff --git a/features/signedout/impl/src/main/res/drawable/ic_devices.xml b/features/signedout/impl/src/main/res/drawable/ic_devices.xml new file mode 100644 index 0000000000..e01de99a1d --- /dev/null +++ b/features/signedout/impl/src/main/res/drawable/ic_devices.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/features/signedout/impl/src/main/res/drawable/ic_do_disturb_alt.xml b/features/signedout/impl/src/main/res/drawable/ic_do_disturb_alt.xml new file mode 100644 index 0000000000..2ac90bd377 --- /dev/null +++ b/features/signedout/impl/src/main/res/drawable/ic_do_disturb_alt.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/features/signedout/impl/src/main/res/drawable/ic_lock_outline.xml b/features/signedout/impl/src/main/res/drawable/ic_lock_outline.xml new file mode 100644 index 0000000000..51831bf3c1 --- /dev/null +++ b/features/signedout/impl/src/main/res/drawable/ic_lock_outline.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/features/signedout/impl/src/main/res/values/localazy.xml b/features/signedout/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..f70b0042d5 --- /dev/null +++ b/features/signedout/impl/src/main/res/values/localazy.xml @@ -0,0 +1,8 @@ + + + "You’ve changed your password on another session" + "You have deleted the session from another session" + "Your server’s administrator has invalidated your access" + "You might have been signed out for one of the reasons listed below. Please sign in again to continue using %s." + "You’re signed out" + diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt new file mode 100644 index 0000000000..208d21154e --- /dev/null +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt @@ -0,0 +1,86 @@ +/* + * 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.features.signedout.impl + +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.matrix.test.core.aBuildMeta +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() + + private val appName = "AppName" + + @Test + fun `present - initial state`() = runTest { + val aSessionData = aSessionData() + val sessionStore = InMemorySessionStore().apply { + storeData(aSessionData) + } + val presenter = createSignedOutPresenter(sessionStore = sessionStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo(appName) + assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + } + } + + @Test + fun `present - sign in again`() = runTest { + val aSessionData = aSessionData() + val sessionStore = InMemorySessionStore().apply { + storeData(aSessionData) + } + val presenter = createSignedOutPresenter(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 createSignedOutPresenter( + sessionId: SessionId = A_SESSION_ID, + sessionStore: SessionStore = InMemorySessionStore(), + ): SignedOutPresenter { + return SignedOutPresenter( + sessionId = sessionId.value, + sessionStore = sessionStore, + buildMeta = aBuildMeta(applicationName = appName), + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf33c7d492..84a6bbe40b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -150,7 +150,7 @@ sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" -sqlite = "androidx.sqlite:sqlite:2.3.1" +sqlite = "androidx.sqlite:sqlite-ktx:2.3.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index c15153876c..501b40508e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -18,12 +18,18 @@ package io.element.android.libraries.matrix.api.auth import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.LoggedInState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface MatrixAuthenticationService { - fun isLoggedIn(): Flow + fun loggedInStateFlow(): Flow suspend fun getLatestSessionId(): SessionId? + + /** + * Restore a session from a [sessionId]. + * Do not restore anything it the access token is not valid anymore. + */ suspend fun restoreSession(sessionId: SessionId): Result fun getHomeserverDetails(): StateFlow suspend fun setHomeserver(homeserver: String): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index ba20cbea2d..8b857d8076 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -126,7 +126,16 @@ class RustMatrixClient constructor( Timber.v("didReceiveAuthError -> do the cleanup") //TODO handle isSoftLogout parameter. appCoroutineScope.launch { - doLogout(doRequest = false) + val existingData = sessionStore.getSession(client.userId()) + if (existingData != null) { + // Set isTokenValid to false + val newData = client.session().toSessionData( + isTokenValid = false, + loginType = existingData.loginType, + ) + sessionStore.updateData(newData) + } + doLogout(doRequest = false, removeSession = false) } } else { Timber.v("didReceiveAuthError -> already cleaning up") @@ -136,7 +145,12 @@ class RustMatrixClient constructor( override fun didRefreshTokens() { Timber.w("didRefreshTokens()") appCoroutineScope.launch { - sessionStore.updateData(client.session().toSessionData()) + val existingData = sessionStore.getSession(client.userId()) ?: return@launch + val newData = client.session().toSessionData( + isTokenValid = existingData.isTokenValid, + loginType = existingData.loginType, + ) + sessionStore.updateData(newData) } } } @@ -328,9 +342,9 @@ class RustMatrixClient constructor( baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false) } - override suspend fun logout(): String? = doLogout(doRequest = true) + override suspend fun logout(): String? = doLogout(doRequest = true, removeSession = true) - private suspend fun doLogout(doRequest: Boolean): String? { + private suspend fun doLogout(doRequest: Boolean, removeSession: Boolean): String? { var result: String? = null withContext(sessionDispatcher) { if (doRequest) { @@ -342,7 +356,9 @@ class RustMatrixClient constructor( } close() baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true) - sessionStore.removeSession(sessionId.value) + if (removeSession) { + sessionStore.removeSession(sessionId.value) + } } return result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index f2acb0b1be..033a5f6073 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -30,6 +30,8 @@ import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -62,7 +64,7 @@ class RustMatrixAuthenticationService @Inject constructor( ) private var currentHomeserver = MutableStateFlow(null) - override fun isLoggedIn(): Flow { + override fun loggedInStateFlow(): Flow { return sessionStore.isLoggedIn() } @@ -74,7 +76,11 @@ class RustMatrixAuthenticationService @Inject constructor( runCatching { val sessionData = sessionStore.getSession(sessionId.value) if (sessionData != null) { - rustMatrixClientFactory.create(sessionData) + if (sessionData.isTokenValid) { + rustMatrixClientFactory.create(sessionData) + } else { + error("Token is not valid") + } } else { error("No session to restore with id $sessionId") } @@ -102,7 +108,12 @@ class RustMatrixAuthenticationService @Inject constructor( withContext(coroutineDispatchers.io) { runCatching { val client = authService.login(username, password, "Element X Android", null) - val sessionData = client.use { it.session().toSessionData() } + val sessionData = client.use { + it.session().toSessionData( + isTokenValid = true, + loginType = LoginType.PASSWORD, + ) + } sessionStore.storeData(sessionData) SessionId(sessionData.userId) }.mapFailure { failure -> @@ -144,7 +155,12 @@ class RustMatrixAuthenticationService @Inject constructor( runCatching { val urlForOidcLogin = pendingOidcAuthenticationData ?: error("You need to call `getOidcUrl()` first") val client = authService.loginWithOidcCallback(urlForOidcLogin, callbackUrl) - val sessionData = client.use { it.session().toSessionData() } + val sessionData = client.use { + it.session().toSessionData( + isTokenValid = true, + loginType = LoginType.OIDC, + ) + } pendingOidcAuthenticationData?.close() pendingOidcAuthenticationData = null sessionStore.storeData(sessionData) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt index 825c6f4397..fe21a460c8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -16,11 +16,15 @@ package io.element.android.libraries.matrix.impl.mapper +import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionData import org.matrix.rustcomponents.sdk.Session import java.util.Date -internal fun Session.toSessionData() = SessionData( +internal fun Session.toSessionData( + isTokenValid: Boolean, + loginType: LoginType, +) = SessionData( userId = userId, deviceId = deviceId, accessToken = accessToken, @@ -29,4 +33,6 @@ internal fun Session.toSessionData() = SessionData( oidcData = oidcData, slidingSyncProxy = slidingSyncProxy, loginTimestamp = Date(), + isTokenValid = isTokenValid, + loginType = loginType, ) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt index 3a9a7fb2b9..2cf6b77a78 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -38,8 +39,8 @@ class FakeAuthenticationService : MatrixAuthenticationService { private var changeServerError: Throwable? = null private var matrixClient: MatrixClient? = null - override fun isLoggedIn(): Flow { - return flowOf(false) + override fun loggedInStateFlow(): Flow { + return flowOf(LoggedInState.NotLoggedIn) } override suspend fun getLatestSessionId(): SessionId? { 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 new file mode 100644 index 0000000000..ddf7ae5e8a --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoggedInState.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.libraries.sessionstorage.api + +sealed interface LoggedInState { + data object NotLoggedIn : LoggedInState + data class LoggedIn( + val sessionId: String, + val isTokenValid: Boolean, + ) : LoggedInState +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoginType.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoginType.kt new file mode 100644 index 0000000000..ce29e3729a --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoginType.kt @@ -0,0 +1,43 @@ +/* + * 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.libraries.sessionstorage.api + +// Imported from Element Android, to be able to migrate from EA to EXA. +enum class LoginType { + PASSWORD, + OIDC, + SSO, + UNSUPPORTED, + CUSTOM, + DIRECT, + UNKNOWN, + QR; + + companion object { + + fun fromName(name: String) = when (name) { + PASSWORD.name -> PASSWORD + OIDC.name -> OIDC + SSO.name -> SSO + UNSUPPORTED.name -> UNSUPPORTED + CUSTOM.name -> CUSTOM + DIRECT.name -> DIRECT + QR.name -> QR + else -> UNKNOWN + } + } +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index e14c3feeab..25a48c0efe 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -27,4 +27,6 @@ data class SessionData( val oidcData: String?, val slidingSyncProxy: String?, val loginTimestamp: Date?, + val isTokenValid: Boolean, + val loginType: LoginType, ) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index 2b3398f76c..cc23353a8f 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map interface SessionStore { - fun isLoggedIn(): Flow + fun isLoggedIn(): Flow fun sessionsFlow(): Flow> suspend fun storeData(sessionData: SessionData) 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 df78149eef..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 @@ -16,6 +16,7 @@ package io.element.android.libraries.sessionstorage.impl.memory +import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow @@ -26,8 +27,17 @@ class InMemorySessionStore : SessionStore { private var sessionDataFlow = MutableStateFlow(null) - override fun isLoggedIn(): Flow { - return sessionDataFlow.map { it != null } + override fun isLoggedIn(): Flow { + return sessionDataFlow.map { + if (it == null) { + LoggedInState.NotLoggedIn + } else { + LoggedInState.LoggedIn( + sessionId = it.userId, + isTokenValid = it.isTokenValid, + ) + } + } } override fun sessionsFlow(): Flow> { diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 698bfcf230..4e76c7de9c 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -45,10 +45,18 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(libs.coroutines.test) testImplementation(libs.sqldelight.driver.jvm) + + coreLibraryDesugaring(libs.android.desugar) } sqldelight { database("SessionDatabase") { + // https://cashapp.github.io/sqldelight/1.5.4/multiplatform_sqlite/migrations/ + // To generate a .db file from your latest schema, run this task + // ./gradlew generateDebugSessionDatabaseSchema + // Test migration by running + // ./gradlew verifySqlDelightMigration + schemaOutputDirectory = File("src/main/sqldelight/databases") verifyMigrations = true } } 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 eb273411a0..0ca63d52a7 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 @@ -22,6 +22,7 @@ import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow @@ -35,11 +36,20 @@ class DatabaseSessionStore @Inject constructor( private val database: SessionDatabase, ) : SessionStore { - override fun isLoggedIn(): Flow { + override fun isLoggedIn(): Flow { return database.sessionDataQueries.selectFirst() .asFlow() .mapToOneOrNull() - .map { it != null } + .map { + if (it == null) { + LoggedInState.NotLoggedIn + } else { + LoggedInState.LoggedIn( + sessionId = it.userId, + isTokenValid = it.isTokenValid == 1L + ) + } + } } override suspend fun storeData(sessionData: SessionData) { diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index d0c89d9896..1a81647f5c 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.sessionstorage.impl +import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionData import java.util.Date import io.element.android.libraries.matrix.session.SessionData as DbSessionData @@ -30,6 +31,8 @@ internal fun SessionData.toDbModel(): DbSessionData { oidcData = oidcData, slidingSyncProxy = slidingSyncProxy, loginTimestamp = loginTimestamp?.time, + isTokenValid = if (isTokenValid) 1L else 0L, + loginType = loginType.name, ) } @@ -42,6 +45,8 @@ internal fun DbSessionData.toApiModel(): SessionData { homeserverUrl = homeserverUrl, oidcData = oidcData, slidingSyncProxy = slidingSyncProxy, - loginTimestamp = loginTimestamp?.let { Date(it) } + loginTimestamp = loginTimestamp?.let { Date(it) }, + isTokenValid = isTokenValid == 1L, + loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name), ) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/3.db b/libraries/session-storage/impl/src/main/sqldelight/databases/3.db new file mode 100644 index 0000000000..58c8e8f09a Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/3.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/4.db b/libraries/session-storage/impl/src/main/sqldelight/databases/4.db new file mode 100644 index 0000000000..f2e7eb964f Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/4.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index 05049c5635..8cebfbe5e2 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -6,7 +6,9 @@ CREATE TABLE SessionData ( homeserverUrl TEXT NOT NULL, slidingSyncProxy TEXT, loginTimestamp INTEGER, - oidcData TEXT + oidcData TEXT, + isTokenValid INTEGER NOT NULL, + loginType TEXT ); diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/3.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/3.sqm new file mode 100644 index 0000000000..c4d0743ff5 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/3.sqm @@ -0,0 +1,2 @@ +ALTER TABLE SessionData ADD COLUMN isTokenValid INTEGER NOT NULL DEFAULT 1; +ALTER TABLE SessionData ADD COLUMN loginType TEXT; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt index e035ff9ae1..4ca932f48f 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt @@ -20,6 +20,8 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver import io.element.android.libraries.matrix.session.SessionData +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.LoginType import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -38,6 +40,8 @@ class DatabaseSessionStoreTests { slidingSyncProxy = null, loginTimestamp = null, oidcData = "aOidcData", + isTokenValid = 1, + loginType = LoginType.UNKNOWN.name, ) @Before @@ -63,11 +67,11 @@ class DatabaseSessionStoreTests { @Test fun `isLoggedIn emits true while there are sessions in the DB`() = runTest { databaseSessionStore.isLoggedIn().test { - assertThat(awaitItem()).isFalse() + assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn) database.sessionDataQueries.insertSessionData(aSessionData) - assertThat(awaitItem()).isTrue() + assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true)) database.sessionDataQueries.removeSession(aSessionData.userId) - assertThat(awaitItem()).isFalse() + assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn) } } @@ -121,6 +125,8 @@ class DatabaseSessionStoreTests { slidingSyncProxy = "slidingSyncProxy", loginTimestamp = 1, oidcData = "aOidcData", + isTokenValid = 1, + loginType = null, ) val secondSessionData = SessionData( userId = "userId", @@ -131,6 +137,8 @@ class DatabaseSessionStoreTests { slidingSyncProxy = "slidingSyncProxyAltered", loginTimestamp = 2, oidcData = "aOidcDataAltered", + isTokenValid = 1, + loginType = null, ) assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId) assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp) diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index da48ac9453..6a7f0ecbb7 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -64,6 +64,7 @@ "Send message" "Share" "Share link" + "Sign in again" "Skip" "Start" "Start chat" diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index d0309ebec7..c95a44d8ea 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.auth.RustMatrixAuthenticationService import io.element.android.libraries.network.useragent.SimpleUserAgentProvider +import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.theme.ElementTheme import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock @@ -64,8 +65,8 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { ElementTheme { - val isLoggedIn by matrixAuthenticationService.isLoggedIn().collectAsState(initial = false) - Content(isLoggedIn = isLoggedIn, modifier = Modifier.fillMaxSize()) + val loggedInState by matrixAuthenticationService.loggedInStateFlow().collectAsState(initial = LoggedInState.NotLoggedIn) + Content(isLoggedIn = loggedInState is LoggedInState.LoggedIn, modifier = Modifier.fillMaxSize()) } } diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.signedout.impl_null_SignedOutView-D-0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.signedout.impl_null_SignedOutView-D-0_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..82a7ca445f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.signedout.impl_null_SignedOutView-D-0_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25d69103d52132a0d47f3e5c4feebc4a981e4d382255e312c83973fff4e0b3e8 +size 60658 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.signedout.impl_null_SignedOutView-N-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.signedout.impl_null_SignedOutView-N-0_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..32059527ae --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.signedout.impl_null_SignedOutView-N-0_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5844f66e9c07ba824fb3d1871281fc385ec7c0898cae0bf8e532bea1ef2278fb +size 58885 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 3842198da6..7e07d269c0 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -25,6 +25,12 @@ "screen_onboarding_.*" ] }, + { + "name": ":features:signedout:impl", + "includeRegex": [ + "screen_signed_out_.*" + ] + }, { "name": ":features:invitelist:impl", "includeRegex": [