diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt index f357869375..e1f87a3c61 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt @@ -16,9 +16,11 @@ package io.element.android.features.lockscreen.impl.unlock +import android.app.Activity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -26,6 +28,8 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.logout.api.util.onSuccessLogout import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -47,6 +51,8 @@ class PinUnlockNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { val state = presenter.present() + val activity = LocalContext.current as Activity + val isDark = ElementTheme.isLightTheme.not() LaunchedEffect(state.isUnlocked) { if (state.isUnlocked) { onUnlock() @@ -57,6 +63,7 @@ class PinUnlockNode @AssistedInject constructor( // UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true. // It's set to false in PinUnlockActivity. isInAppUnlock = true, + onSuccessLogout = { onSuccessLogout(activity, isDark, it) }, modifier = modifier ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt index b563b3ac70..c2f3b81873 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt @@ -30,6 +30,7 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.logout.api.LogoutUseCase +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState @@ -61,7 +62,7 @@ class PinUnlockPresenter @Inject constructor( mutableStateOf(false) } val signOutAction = remember { - mutableStateOf>(AsyncData.Uninitialized) + mutableStateOf>(AsyncAction.Uninitialized) } var biometricUnlockResult by remember { mutableStateOf(null) @@ -177,7 +178,7 @@ class PinUnlockPresenter @Inject constructor( } } - private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { + private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { suspend { logoutUseCase.logout(ignoreSdkError = true) }.runCatchingUpdatingState(signOutAction) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt index cfe371eb3f..53a44609ef 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt @@ -19,6 +19,7 @@ package io.element.android.features.lockscreen.impl.unlock import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData data class PinUnlockState( @@ -26,7 +27,7 @@ data class PinUnlockState( val showWrongPinTitle: Boolean, val remainingAttempts: AsyncData, val showSignOutPrompt: Boolean, - val signOutAction: AsyncData, + val signOutAction: AsyncAction, val showBiometricUnlock: Boolean, val isUnlocked: Boolean, val biometricUnlockResult: BiometricUnlock.AuthenticationResult?, diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt index 151c3079ee..8cd55f141d 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt @@ -19,6 +19,7 @@ package io.element.android.features.lockscreen.impl.unlock import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData open class PinUnlockStateProvider : PreviewParameterProvider { @@ -30,7 +31,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider { aPinUnlockState(showSignOutPrompt = true), aPinUnlockState(showBiometricUnlock = false), aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0), - aPinUnlockState(signOutAction = AsyncData.Loading()), + aPinUnlockState(signOutAction = AsyncAction.Loading), ) } @@ -42,7 +43,7 @@ fun aPinUnlockState( showBiometricUnlock: Boolean = true, biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null, isUnlocked: Boolean = false, - signOutAction: AsyncData = AsyncData.Uninitialized, + signOutAction: AsyncAction = AsyncAction.Uninitialized, ) = PinUnlockState( pinEntry = AsyncData.Success(pinEntry), showWrongPinTitle = showWrongPinTitle, diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt index 70b6a6c634..494135e6c2 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt @@ -39,7 +39,9 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -56,6 +58,7 @@ import io.element.android.features.lockscreen.impl.components.PinEntryTextField import io.element.android.features.lockscreen.impl.pin.model.PinDigit import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.components.ProgressDialog @@ -74,6 +77,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun PinUnlockView( state: PinUnlockState, isInAppUnlock: Boolean, + onSuccessLogout: (logoutUrlResult: String?) -> Unit, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -91,9 +95,21 @@ fun PinUnlockView( onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) }, ) } - if (state.signOutAction is AsyncData.Loading) { - ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + when (state.signOutAction) { + AsyncAction.Loading -> { + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + } + is AsyncAction.Success -> { + val latestOnSuccessLogout by rememberUpdatedState(onSuccessLogout) + LaunchedEffect(state) { + latestOnSuccessLogout(state.signOutAction.data) + } + } + AsyncAction.Confirming, + is AsyncAction.Failure, + AsyncAction.Uninitialized -> Unit } + if (state.showBiometricUnlockError) { ErrorDialog( content = state.biometricUnlockErrorMessage ?: "", @@ -363,6 +379,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider: PinUnlockView( state = state, isInAppUnlock = true, + onSuccessLogout = {}, ) } } @@ -374,6 +391,7 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas PinUnlockView( state = state, isInAppUnlock = false, + onSuccessLogout = {}, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt index 7b7b16790f..88e8daf9db 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt @@ -24,11 +24,13 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope +import io.element.android.compound.theme.ElementTheme import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter import io.element.android.features.lockscreen.impl.unlock.PinUnlockView import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings +import io.element.android.features.logout.api.util.onSuccessLogout import io.element.android.libraries.architecture.bindings import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.preferences.api.store.AppPreferencesStore @@ -53,7 +55,12 @@ class PinUnlockActivity : AppCompatActivity() { setContent { ElementThemeApp(appPreferencesStore) { val state = presenter.present() - PinUnlockView(state = state, isInAppUnlock = false) + val isDark = ElementTheme.isLightTheme.not() + PinUnlockView( + state = state, + isInAppUnlock = false, + onSuccessLogout = { onSuccessLogout(this, isDark, it) }, + ) } } lifecycleScope.launch { diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt index 8299b5a1ab..3bb8998663 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt @@ -29,6 +29,7 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.assertText import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel import io.element.android.features.logout.test.FakeLogoutUseCase +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -51,7 +52,7 @@ class PinUnlockPresenterTest { assertThat(state.showWrongPinTitle).isFalse() assertThat(state.showSignOutPrompt).isFalse() assertThat(state.isUnlocked).isFalse() - assertThat(state.signOutAction).isInstanceOf(AsyncData.Uninitialized::class.java) + assertThat(state.signOutAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Uninitialized::class.java) } awaitItem().also { state -> @@ -106,7 +107,7 @@ class PinUnlockPresenterTest { @Test fun `present - forgot pin flow`() = runTest { - val signOutLambda = lambdaRecorder { "" } + val signOutLambda = lambdaRecorder { "" } val signOut = FakeLogoutUseCase(signOutLambda) val presenter = createPinUnlockPresenter(this, logoutUseCase = signOut) moleculeFlow(RecompositionMode.Immediate) { @@ -133,7 +134,7 @@ class PinUnlockPresenterTest { } skipItems(2) awaitItem().also { state -> - assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.signOutAction).isInstanceOf(AsyncAction.Success::class.java) } assert(signOutLambda).isCalledOnce() } diff --git a/features/logout/api/build.gradle.kts b/features/logout/api/build.gradle.kts index 85532f5617..655ab29ee5 100644 --- a/features/logout/api/build.gradle.kts +++ b/features/logout/api/build.gradle.kts @@ -22,6 +22,7 @@ android { } dependencies { + implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt index a06b7117b1..692074dafb 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt @@ -23,9 +23,10 @@ interface LogoutUseCase { /** * Log out the current user and then perform any needed cleanup tasks. * @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway. - * @return the session id of the logged out user. + * @return an optional URL. When the URL is there, it should be presented to the user after logout for + * Relying Party (RP) initiated logout on their account page. */ - suspend fun logout(ignoreSdkError: Boolean): String + suspend fun logout(ignoreSdkError: Boolean): String? interface Factory { fun create(sessionId: String): LogoutUseCase diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/util/Util.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/util/Util.kt new file mode 100644 index 0000000000..81d224bdf1 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/util/Util.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 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 + * + * https://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.logout.api.util + +import android.app.Activity +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import timber.log.Timber + +fun onSuccessLogout( + activity: Activity, + darkTheme: Boolean, + url: String?, +) { + Timber.d("Success logout with result url: $url") + url?.let { + activity.openUrlInChromeCustomTab(null, darkTheme, it) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt index 8e5f08a87a..ffd8d11406 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt @@ -35,9 +35,9 @@ class DefaultLogoutUseCase @AssistedInject constructor( override fun create(sessionId: String): DefaultLogoutUseCase } - override suspend fun logout(ignoreSdkError: Boolean): String { + override suspend fun logout(ignoreSdkError: Boolean): String? { val matrixClient = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrThrow() - matrixClient.logout(ignoreSdkError = ignoreSdkError) - return sessionId + val result = matrixClient.logout(ignoreSdkError = ignoreSdkError) + return result } } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt index 4f5bab10b9..8c6948b227 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt @@ -27,10 +27,10 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.compound.theme.ElementTheme import io.element.android.features.logout.api.LogoutEntryPoint -import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.features.logout.api.util.onSuccessLogout import io.element.android.libraries.di.SessionScope -import timber.log.Timber @ContributesNode(SessionScope::class) class LogoutNode @AssistedInject constructor( @@ -42,21 +42,15 @@ class LogoutNode @AssistedInject constructor( plugins().forEach { it.onChangeRecoveryKeyClick() } } - private fun onSuccessLogout(activity: Activity, url: String?) { - Timber.d("Success logout with result url: $url") - url?.let { - activity.openUrlInChromeCustomTab(null, false, it) - } - } - @Composable override fun View(modifier: Modifier) { val state = presenter.present() val activity = LocalContext.current as Activity + val isDark = ElementTheme.isLightTheme.not() LogoutView( state = state, onChangeRecoveryKeyClick = ::onChangeRecoveryKeyClick, - onSuccessLogout = { onSuccessLogout(activity, it) }, + onSuccessLogout = { onSuccessLogout(activity, isDark, it) }, onBackClick = ::navigateUp, modifier = modifier, ) diff --git a/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt index bbc67765d1..452d466032 100644 --- a/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt +++ b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt @@ -20,9 +20,9 @@ import io.element.android.features.logout.api.LogoutUseCase import io.element.android.tests.testutils.lambda.lambdaError class FakeLogoutUseCase( - var logoutLambda: (Boolean) -> String = lambdaError() + var logoutLambda: (Boolean) -> String? = { lambdaError() } ) : LogoutUseCase { - override suspend fun logout(ignoreSdkError: Boolean): String { + override suspend fun logout(ignoreSdkError: Boolean): String? { return logoutLambda(ignoreSdkError) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 106f07ecc2..b05031661b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -30,10 +30,10 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.compound.theme.ElementTheme import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutView +import io.element.android.features.logout.api.util.onSuccessLogout import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.user.MatrixUser -import timber.log.Timber @ContributesNode(SessionScope::class) class PreferencesRootNode @AssistedInject constructor( @@ -94,13 +94,6 @@ class PreferencesRootNode @AssistedInject constructor( } } - private fun onSuccessLogout(activity: Activity, url: String?) { - Timber.d("Success (direct) logout with result url: $url") - url?.let { - activity.openUrlInChromeCustomTab(null, false, it) - } - } - private fun onOpenNotificationSettings() { plugins().forEach { it.onOpenNotificationSettings() } } @@ -153,7 +146,7 @@ class PreferencesRootNode @AssistedInject constructor( directLogoutView.Render( state = state.directLogoutState, onSuccessLogout = { - onSuccessLogout(activity, it) + onSuccessLogout(activity, isDark, it) } ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 9fb1c8b37f..75c1a19f39 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -166,7 +166,7 @@ class DeveloperSettingsPresenterTest { @Test fun `present - toggling simplified sliding sync changes the preferences and logs out the user`() = runTest { - val logoutCallRecorder = lambdaRecorder { "" } + val logoutCallRecorder = lambdaRecorder { "" } val logoutUseCase = FakeLogoutUseCase(logoutLambda = logoutCallRecorder) val preferences = InMemoryAppPreferencesStore() val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences, logoutUseCase = logoutUseCase)