diff --git a/changelog.d/1127.feature b/changelog.d/1127.feature new file mode 100644 index 0000000000..da2c6b3cf1 --- /dev/null +++ b/changelog.d/1127.feature @@ -0,0 +1 @@ +Enable OIDC support. diff --git a/docs/oidc.md b/docs/oidc.md index 5f9e70268d..0e4ad44852 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -45,3 +45,7 @@ state: ex6mNJVFZ5jn9wL8 Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs + + +Test server: +synapse-oidc.lab.element.dev diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index 1a021ad605..61ef4d724b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -17,6 +17,7 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -26,8 +27,11 @@ import androidx.compose.runtime.rememberCoroutineScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.features.login.impl.DefaultLoginUserStory import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState @@ -40,7 +44,9 @@ import java.net.URL class ConfirmAccountProviderPresenter @AssistedInject constructor( @Assisted private val params: Params, private val accountProviderDataSource: AccountProviderDataSource, - private val authenticationService: MatrixAuthenticationService + private val authenticationService: MatrixAuthenticationService, + private val defaultOidcActionFlow: DefaultOidcActionFlow, + private val defaultLoginUserStory: DefaultLoginUserStory, ) : Presenter { data class Params( @@ -61,6 +67,14 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor( mutableStateOf(Async.Uninitialized) } + LaunchedEffect(Unit) { + launch { + defaultOidcActionFlow.collect { + onOidcAction(it, loginFlowAction) + } + } + } + fun handleEvents(event: ConfirmAccountProviderEvents) { when (event) { ConfirmAccountProviderEvents.Continue -> { @@ -97,4 +111,33 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor( }.getOrThrow() }.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from) } + + private suspend fun onOidcAction( + oidcAction: OidcAction?, + loginFlowAction: MutableState>, + ) { + oidcAction ?: return + loginFlowAction.value = Async.Loading() + when (oidcAction) { + OidcAction.GoBack -> { + authenticationService.cancelOidcLogin() + .onSuccess { + loginFlowAction.value = Async.Uninitialized + } + .onFailure { failure -> + loginFlowAction.value = Async.Failure(failure) + } + } + is OidcAction.Success -> { + authenticationService.loginWithOidc(oidcAction.url) + .onSuccess { _ -> + defaultLoginUserStory.setLoginFlowIsDone(true) + } + .onFailure { failure -> + loginFlowAction.value = Async.Failure(failure) + } + } + } + defaultOidcActionFlow.reset() + } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt index e8bcea990e..152b1a094c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt @@ -21,7 +21,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider object LoginConstants { const val MATRIX_ORG_URL = "matrix.org" - const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev" + const val DEFAULT_HOMESERVER_URL = "matrix.org" const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 76a3ad3d22..95cd9bf053 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -20,24 +20,25 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.features.login.impl.DefaultLoginUserStory import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow import io.element.android.features.login.impl.util.defaultAccountProvider import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.tests.testutils.waitForPredicate import kotlinx.coroutines.test.runTest import org.junit.Test class ConfirmAccountProviderPresenterTest { @Test fun `present - initial test`() = runTest { - val presenter = ConfirmAccountProviderPresenter( - ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - AccountProviderDataSource(), - FakeAuthenticationService(), - ) + val presenter = createConfirmAccountProviderPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -51,13 +52,11 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue password login`() = runTest { - val authServer = FakeAuthenticationService() - val presenter = ConfirmAccountProviderPresenter( - ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - AccountProviderDataSource(), - authServer, + val authenticationService = FakeAuthenticationService() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, ) - authServer.givenHomeserver(A_HOMESERVER) + authenticationService.givenHomeserver(A_HOMESERVER) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -75,13 +74,11 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue oidc`() = runTest { - val authServer = FakeAuthenticationService() - val presenter = ConfirmAccountProviderPresenter( - ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - AccountProviderDataSource(), - authServer, + val authenticationService = FakeAuthenticationService() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, ) - authServer.givenHomeserver(A_HOMESERVER_OIDC) + authenticationService.givenHomeserver(A_HOMESERVER_OIDC) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -97,19 +94,135 @@ class ConfirmAccountProviderPresenterTest { } } + @Test + fun `present - oidc - cancel with failure`() = runTest { + val authenticationService = FakeAuthenticationService() + val defaultOidcActionFlow = DefaultOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + authenticationService.givenHomeserver(A_HOMESERVER_OIDC) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) + authenticationService.givenOidcCancelError(A_THROWABLE) + defaultOidcActionFlow.post(OidcAction.GoBack) + val cancelFailureState = awaitItem() + assertThat(cancelFailureState.loginFlow).isInstanceOf(Async.Failure::class.java) + } + } + + @Test + fun `present - oidc - cancel with success`() = runTest { + val authenticationService = FakeAuthenticationService() + val defaultOidcActionFlow = DefaultOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + authenticationService.givenHomeserver(A_HOMESERVER_OIDC) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) + defaultOidcActionFlow.post(OidcAction.GoBack) + val cancelFinalState = awaitItem() + assertThat(cancelFinalState.loginFlow).isInstanceOf(Async.Uninitialized::class.java) + } + } + + @Test + fun `present - oidc - success with failure`() = runTest { + val authenticationService = FakeAuthenticationService() + val defaultOidcActionFlow = DefaultOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + authenticationService.givenHomeserver(A_HOMESERVER_OIDC) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) + authenticationService.givenLoginError(A_THROWABLE) + defaultOidcActionFlow.post(OidcAction.Success("aUrl")) + val cancelLoadingState = awaitItem() + assertThat(cancelLoadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val cancelFailureState = awaitItem() + assertThat(cancelFailureState.loginFlow).isInstanceOf(Async.Failure::class.java) + } + } + + @Test + fun `present - oidc - success with success`() = runTest { + val authenticationService = FakeAuthenticationService() + val defaultOidcActionFlow = DefaultOidcActionFlow() + val defaultLoginUserStory = DefaultLoginUserStory().apply { + setLoginFlowIsDone(false) + } + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + defaultLoginUserStory = defaultLoginUserStory, + ) + authenticationService.givenHomeserver(A_HOMESERVER_OIDC) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) + assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse() + defaultOidcActionFlow.post(OidcAction.Success("aUrl")) + val successSuccessState = awaitItem() + assertThat(successSuccessState.loginFlow).isInstanceOf(Async.Loading::class.java) + waitForPredicate { defaultLoginUserStory.loginFlowIsDone.value } + } + } + @Test fun `present - submit fails`() = runTest { - val authServer = FakeAuthenticationService() - val presenter = ConfirmAccountProviderPresenter( - ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - AccountProviderDataSource(), - authServer, + val authenticationService = FakeAuthenticationService() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - authServer.givenChangeServerError(Throwable()) + authenticationService.givenChangeServerError(Throwable()) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) skipItems(1) // Loading val failureState = awaitItem() @@ -121,10 +234,8 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - clear error`() = runTest { val authenticationService = FakeAuthenticationService() - val presenter = ConfirmAccountProviderPresenter( - ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - AccountProviderDataSource(), - authenticationService, + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -147,4 +258,18 @@ class ConfirmAccountProviderPresenterTest { assertThat(clearedState.loginFlow).isEqualTo(Async.Uninitialized) } } + + private fun createConfirmAccountProviderPresenter( + params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(), + matrixAuthenticationService: MatrixAuthenticationService = FakeAuthenticationService(), + defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(), + defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(), + ) = ConfirmAccountProviderPresenter( + params = params, + accountProviderDataSource = accountProviderDataSource, + authenticationService = matrixAuthenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + defaultLoginUserStory = defaultLoginUserStory, + ) } diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceScreen.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceScreen.kt index 60844f4477..f9f23ac9c4 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceScreen.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceScreen.kt @@ -34,12 +34,12 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight @Composable fun LogoutPreferenceView( state: LogoutPreferenceState, - onSuccessLogout: () -> Unit = {} + onSuccessLogout: (String?) -> Unit = {} ) { val eventSink = state.eventSink if (state.logoutAction is Async.Success) { LaunchedEffect(state.logoutAction) { - onSuccessLogout() + onSuccessLogout(state.logoutAction.data) } return } diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt index e5fd05ba8e..95550ef4c8 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt @@ -19,6 +19,6 @@ package io.element.android.features.logout.api import io.element.android.libraries.architecture.Async data class LogoutPreferenceState( - val logoutAction: Async, + val logoutAction: Async, val eventSink: (LogoutPreferenceEvents) -> Unit, ) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt index e957755b98..2fece4449b 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt @@ -40,7 +40,7 @@ class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixCli @Composable override fun present(): LogoutPreferenceState { val localCoroutineScope = rememberCoroutineScope() - val logoutAction: MutableState> = remember { + val logoutAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } @@ -56,7 +56,7 @@ class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixCli ) } - private fun CoroutineScope.logout(logoutAction: MutableState>) = launch { + private fun CoroutineScope.logout(logoutAction: MutableState>) = launch { suspend { matrixClient.logout() }.runCatchingUpdatingState(logoutAction) diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index c88beff81a..105cb1fc7b 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -26,11 +26,13 @@ "You can change it in your %1$s." "global settings" "Default setting" + "Remove custom setting" "An error occurred while loading notification settings." "Failed restoring the default mode, please try again." "Failed setting the mode, please try again." "All messages" "Mentions and Keywords only" + "In this room, notify me for" "Show less" "Show more" "Send again" 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 a564927101..e90569b40e 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 @@ -16,8 +16,10 @@ package io.element.android.features.preferences.impl.root +import android.app.Activity import androidx.compose.runtime.Composable 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 @@ -25,7 +27,9 @@ 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.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.di.SessionScope +import timber.log.Timber @ContributesNode(SessionScope::class) class PreferencesRootNode @AssistedInject constructor( @@ -62,9 +66,16 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenAbout() } } + private fun onManageAccountClicked(activity: Activity, accountManagementUrl: String?) { + accountManagementUrl?.let { + activity.openUrlInChromeCustomTab(null, false, it) + } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() + val activity = LocalContext.current as Activity PreferencesRootView( state = state, modifier = modifier, @@ -73,7 +84,16 @@ class PreferencesRootNode @AssistedInject constructor( onOpenAnalytics = this::onOpenAnalytics, onOpenAbout = this::onOpenAbout, onVerifyClicked = this::onVerifyClicked, - onOpenDeveloperSettings = this::onOpenDeveloperSettings + onOpenDeveloperSettings = this::onOpenDeveloperSettings, + onSuccessLogout = { onSuccessLogout(activity, it) }, + onManageAccountClicked = { onManageAccountClicked(activity, state.accountManagementUrl) }, ) } + + private fun onSuccessLogout(activity: Activity, url: String?) { + Timber.d("Success logout with result url: $url") + url?.let { + activity.openUrlInChromeCustomTab(null, false, it) + } + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 0cd2e7f7db..b33940b21e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -68,6 +68,14 @@ class PreferencesRootPresenter @Inject constructor( derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified } } + val accountManagementUrl: MutableState = remember { + mutableStateOf(null) + } + + LaunchedEffect(Unit) { + initAccountManagementUrl(accountManagementUrl) + } + val logoutState = logoutPresenter.present() val showDeveloperSettings = buildType != BuildType.RELEASE return PreferencesRootState( @@ -75,6 +83,7 @@ class PreferencesRootPresenter @Inject constructor( myUser = matrixUser.value, version = versionFormatter.get(), showCompleteVerification = sessionIsNotVerified, + accountManagementUrl = accountManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, showDeveloperSettings = showDeveloperSettings, snackbarMessage = snackbarMessage, @@ -84,4 +93,8 @@ class PreferencesRootPresenter @Inject constructor( private fun CoroutineScope.initialLoad(matrixUser: MutableState) = launch { matrixUser.value = matrixClient.getCurrentUser() } + + private fun CoroutineScope.initAccountManagementUrl(accountManagementUrl: MutableState) = launch { + accountManagementUrl.value = matrixClient.getAccountManagementUrl().getOrNull() + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 540c470815..af3a090630 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -25,6 +25,7 @@ data class PreferencesRootState( val myUser: MatrixUser?, val version: String, val showCompleteVerification: Boolean, + val accountManagementUrl: String?, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val snackbarMessage: SnackbarMessage?, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index e8c148267f..931a560c1d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -25,6 +25,7 @@ fun aPreferencesRootState() = PreferencesRootState( myUser = null, version = "Version 1.1 (1)", showCompleteVerification = true, + accountManagementUrl = "aUrl", showAnalyticsSettings = true, showDeveloperSettings = true, snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 4b589ad2b0..c24a2ec875 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.DeveloperMode import androidx.compose.material.icons.outlined.Help import androidx.compose.material.icons.outlined.InsertChart +import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -51,10 +52,12 @@ fun PreferencesRootView( state: PreferencesRootState, onBackPressed: () -> Unit, onVerifyClicked: () -> Unit, + onManageAccountClicked: () -> Unit, onOpenAnalytics: () -> Unit, onOpenRageShake: () -> Unit, onOpenAbout: () -> Unit, onOpenDeveloperSettings: () -> Unit, + onSuccessLogout: (String?) -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -75,6 +78,13 @@ fun PreferencesRootView( ) HorizontalDivider() } + if (state.accountManagementUrl != null) { + PreferenceText( + title = stringResource(id = CommonStrings.screen_settings_oidc_account), + icon = Icons.Outlined.ManageAccounts, + onClick = onManageAccountClicked, + ) + } if (state.showAnalyticsSettings) { PreferenceText( title = stringResource(id = CommonStrings.common_analytics), @@ -98,6 +108,7 @@ fun PreferencesRootView( HorizontalDivider() LogoutPreferenceView( state = state.logoutState, + onSuccessLogout = onSuccessLogout, ) Text( modifier = Modifier @@ -140,5 +151,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onOpenDeveloperSettings = {}, onOpenAbout = {}, onVerifyClicked = {}, + onSuccessLogout = {}, + onManageAccountClicked = {}, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 580426fcfa..5d508912a8 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -64,6 +64,7 @@ class PreferencesRootPresenterTest { ) assertThat(loadedState.showDeveloperSettings).isEqualTo(true) assertThat(loadedState.showAnalyticsSettings).isEqualTo(false) + assertThat(loadedState.accountManagementUrl).isNull() } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d79df0df1a..d664e32530 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -146,7 +146,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.44" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.45" sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 67c0625a91..be64a3371f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -55,9 +55,15 @@ interface MatrixClient : Closeable { * Will close the client and delete the cache data. */ suspend fun clearCache() - suspend fun logout() + + /** + * Logout the user. + * Returns an optional URL. When the URL is there, it should be presented to the user after logout for RP initiated logout on their account page. + */ + suspend fun logout(): String? suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result + suspend fun getAccountManagementUrl(): Result suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result fun roomMembershipObserver(): RoomMembershipObserver diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt index 48712b7ddf..9620dc6d7f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt @@ -22,6 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) { class SlidingSyncNotAvailable(message: String) : AuthenticationException(message) class SessionMissing(message: String) : AuthenticationException(message) class Generic(message: String) : AuthenticationException(message) - // TODO Oidc - // class OidcError(type: String, message: String) : AuthenticationException(message) + data class OidcError(val type: String, override val message: String) : AuthenticationException(message) } 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 8f5cfa496b..c68913b705 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 @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.impl.core.toProgressWatcher +import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.pushers.RustPushersService @@ -119,6 +120,13 @@ class RustMatrixClient constructor( Timber.v("didReceiveAuthError -> already cleaning up") } } + + override fun didRefreshTokens() { + Timber.w("didRefreshTokens()") + appCoroutineScope.launch { + sessionStore.updateData(client.session().toSessionData()) + } + } } private val rustRoomListService: RoomListService = @@ -287,21 +295,30 @@ class RustMatrixClient constructor( baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false) } - override suspend fun logout() = doLogout(doRequest = true) + override suspend fun logout(): String? = doLogout(doRequest = true) - private suspend fun doLogout(doRequest: Boolean) = withContext(sessionDispatcher) { - if (doRequest) { - try { - client.logout() - } catch (failure: Throwable) { - Timber.e(failure, "Fail to call logout on HS. Still delete local files.") + private suspend fun doLogout(doRequest: Boolean): String? { + var result: String? = null + withContext(sessionDispatcher) { + if (doRequest) { + try { + result = client.logout() + } catch (failure: Throwable) { + Timber.e(failure, "Fail to call logout on HS. Still delete local files.") + } } + close() + baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true) + sessionStore.removeSession(sessionId.value) } - close() - baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true) - sessionStore.removeSession(sessionId.value) + return result } + override suspend fun getAccountManagementUrl(): Result = withContext(sessionDispatcher) { + runCatching { + client.accountUrl() + } + } override suspend fun loadUserDisplayName(): Result = withContext(sessionDispatcher) { runCatching { client.displayName() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 931133c266..bb80032230 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -75,4 +75,5 @@ private fun SessionData.toSession() = Session( deviceId = deviceId, homeserverUrl = homeserverUrl, slidingSyncProxy = slidingSyncProxy, + oidcData = oidcData, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index f0feb2857d..8f7f63e503 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -26,15 +26,12 @@ fun Throwable.mapAuthenticationException(): AuthenticationException { is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!) is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!) is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!) - - /* TODO Oidc is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!) is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!) is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!) - is RustAuthenticationException.OidcNotStarted -> AuthenticationException.OidcError("OidcNotStarted", message!!) is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", message!!) - */ - + is RustAuthenticationException.OidcCancelled -> AuthenticationException.OidcError("OidcCancelled", message!!) + is RustAuthenticationException.OidcCallbackUrlInvalid -> AuthenticationException.OidcError("OidcCallbackUrlInvalid", message!!) else -> AuthenticationException.Generic(this.message ?: "Unknown error") } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt index a3d277c6da..f1d3e34bf8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt @@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use { MatrixHomeServerDetails( url = url(), supportsPasswordLogin = supportsPasswordLogin(), - supportsOidcLogin = false // TODO Oidc supportsOidcLogin(), + supportsOidcLogin = supportsOidcLogin(), ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt index b5115ffad4..401fa0ce83 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt @@ -16,17 +16,19 @@ package io.element.android.libraries.matrix.impl.auth -// TODO Oidc -// import io.element.android.libraries.matrix.api.auth.OidcConfig -// import org.matrix.rustcomponents.sdk.OidcClientMetadata +import io.element.android.libraries.matrix.api.auth.OidcConfig +import org.matrix.rustcomponents.sdk.OidcConfiguration -/* -val oidcClientMetadata: OidcClientMetadata = OidcClientMetadata( +val oidcConfiguration: OidcConfiguration = OidcConfiguration( clientName = "Element", redirectUri = OidcConfig.redirectUri, clientUri = "https://element.io", tosUri = "https://element.io/user-terms-of-service", - policyUri = "https://element.io/privacy" + policyUri = "https://element.io/privacy", + /** + * Some homeservers/auth issuers don't support dynamic client registration, and have to be registered manually + */ + staticRegistrations = mapOf( + "https://id.thirdroom.io/realms/thirdroom" to "elementx", + ), ) - */ - 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 ba06891013..6014644733 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 @@ -16,8 +16,6 @@ package io.element.android.libraries.matrix.impl.auth -// TODO Oidc -// import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure @@ -30,17 +28,16 @@ import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.core.SessionId 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.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.Session +import org.matrix.rustcomponents.sdk.OidcAuthenticationData import org.matrix.rustcomponents.sdk.use import java.io.File -import java.util.Date import javax.inject.Inject import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService @@ -57,9 +54,8 @@ class RustMatrixAuthenticationService @Inject constructor( private val authService: RustAuthenticationService = RustAuthenticationService( basePath = baseDirectory.absolutePath, passphrase = null, - // TODO Oidc - // oidcClientMetadata = oidcClientMetadata, userAgent = userAgentProvider.provide(), + oidcConfiguration = oidcConfiguration, customSlidingSyncProxy = null, ) private var currentHomeserver = MutableStateFlow(null) @@ -112,68 +108,48 @@ class RustMatrixAuthenticationService @Inject constructor( } } - // TODO Oidc - // private var pendingUrlForOidcLogin: OidcAuthenticationUrl? = null + private var pendingOidcAuthenticationData: OidcAuthenticationData? = null override suspend fun getOidcUrl(): Result { - TODO("Oidc") - /* return withContext(coroutineDispatchers.io) { runCatching { - val urlForOidcLogin = authService.urlForOidcLogin() - val url = urlForOidcLogin.loginUrl() - pendingUrlForOidcLogin = urlForOidcLogin + val oidcAuthenticationData = authService.urlForOidcLogin() + val url = oidcAuthenticationData.loginUrl() + pendingOidcAuthenticationData = oidcAuthenticationData OidcDetails(url) }.mapFailure { failure -> failure.mapAuthenticationException() } } - */ } override suspend fun cancelOidcLogin(): Result { - TODO("Oidc") - /* return withContext(coroutineDispatchers.io) { runCatching { - pendingUrlForOidcLogin?.close() - pendingUrlForOidcLogin = null + pendingOidcAuthenticationData?.close() + pendingOidcAuthenticationData = null }.mapFailure { failure -> failure.mapAuthenticationException() } } - */ } /** * callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters). */ override suspend fun loginWithOidc(callbackUrl: String): Result { - TODO("Oidc") - /* return withContext(coroutineDispatchers.io) { runCatching { - val urlForOidcLogin = pendingUrlForOidcLogin ?: error("You need to call `getOidcUrl()` first") + val urlForOidcLogin = pendingOidcAuthenticationData ?: error("You need to call `getOidcUrl()` first") val client = authService.loginWithOidcCallback(urlForOidcLogin, callbackUrl) val sessionData = client.use { it.session().toSessionData() } - pendingUrlForOidcLogin = null + pendingOidcAuthenticationData?.close() + pendingOidcAuthenticationData = null sessionStore.storeData(sessionData) SessionId(sessionData.userId) }.mapFailure { failure -> failure.mapAuthenticationException() } } - */ } - } - -private fun Session.toSessionData() = SessionData( - userId = userId, - deviceId = deviceId, - accessToken = accessToken, - refreshToken = refreshToken, - homeserverUrl = homeserverUrl, - slidingSyncProxy = slidingSyncProxy, - loginTimestamp = Date(), -) 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 new file mode 100644 index 0000000000..825c6f4397 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -0,0 +1,32 @@ +/* + * 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.matrix.impl.mapper + +import io.element.android.libraries.sessionstorage.api.SessionData +import org.matrix.rustcomponents.sdk.Session +import java.util.Date + +internal fun Session.toSessionData() = SessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, + oidcData = oidcData, + slidingSyncProxy = slidingSyncProxy, + loginTimestamp = Date(), +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 1229836e30..83f7f3ad79 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -51,6 +51,7 @@ class FakeMatrixClient( private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), private val syncService: FakeSyncService = FakeSyncService(), + private val accountManagementUrlString: Result = Result.success(null), ) : MatrixClient { private var ignoreUserResult: Result = Result.success(Unit) @@ -109,9 +110,10 @@ class FakeMatrixClient( override suspend fun clearCache() { } - override suspend fun logout() { + override suspend fun logout(): String? { delay(100) logoutFailure?.let { throw it } + return null } override fun close() = Unit @@ -124,6 +126,9 @@ class FakeMatrixClient( return userAvatarURLString } + override suspend fun getAccountManagementUrl(): Result { + return accountManagementUrlString + } override suspend fun uploadMedia( mimeType: String, data: ByteArray, 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 cc106f960a..e14c3feeab 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 @@ -24,6 +24,7 @@ data class SessionData( val accessToken: String, val refreshToken: String?, val homeserverUrl: String, + val oidcData: String?, val slidingSyncProxy: String?, val loginTimestamp: Date?, ) 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 d79d700030..2b3398f76c 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 @@ -23,6 +23,12 @@ interface SessionStore { fun isLoggedIn(): Flow fun sessionsFlow(): Flow> suspend fun storeData(sessionData: SessionData) + + /** + * Will update the session data matching the userId, except the value of loginTimestamp. + * No op if userId is not found in DB. + */ + suspend fun updateData(sessionData: SessionData) suspend fun getSession(sessionId: String): SessionData? suspend fun getAllSessions(): List suspend fun getLatestSession(): 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 e23e34983c..df78149eef 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 @@ -38,6 +38,10 @@ class InMemorySessionStore : SessionStore { sessionDataFlow.value = sessionData } + override suspend fun updateData(sessionData: SessionData) { + sessionDataFlow.value = sessionData + } + override suspend fun getSession(sessionId: String): SessionData? { return sessionDataFlow.value.takeIf { it?.userId == sessionId } } 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 9394b66e66..eb273411a0 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 @@ -46,6 +46,24 @@ class DatabaseSessionStore @Inject constructor( database.sessionDataQueries.insertSessionData(sessionData.toDbModel()) } + override suspend fun updateData(sessionData: SessionData) { + val result = database.sessionDataQueries.selectByUserId(sessionData.userId) + .executeAsOneOrNull() + ?.toApiModel() + + if (result == null) { + Timber.e("User ${sessionData.userId} not found in session database") + return + } + + // Copy new data from SDK, but keep login timestamp + database.sessionDataQueries.updateSession( + sessionData.copy( + loginTimestamp = result.loginTimestamp, + ).toDbModel() + ) + } + override suspend fun getLatestSession(): SessionData? { return database.sessionDataQueries.selectFirst() .executeAsOneOrNull() 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 dbb42a8451..d0c89d9896 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 @@ -27,6 +27,7 @@ internal fun SessionData.toDbModel(): DbSessionData { accessToken = accessToken, refreshToken = refreshToken, homeserverUrl = homeserverUrl, + oidcData = oidcData, slidingSyncProxy = slidingSyncProxy, loginTimestamp = loginTimestamp?.time, ) @@ -39,6 +40,7 @@ internal fun DbSessionData.toApiModel(): SessionData { accessToken = accessToken, refreshToken = refreshToken, homeserverUrl = homeserverUrl, + oidcData = oidcData, slidingSyncProxy = slidingSyncProxy, loginTimestamp = loginTimestamp?.let { Date(it) } ) 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 c3123f2ffb..05049c5635 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 @@ -5,7 +5,8 @@ CREATE TABLE SessionData ( refreshToken TEXT, homeserverUrl TEXT NOT NULL, slidingSyncProxy TEXT, - loginTimestamp INTEGER + loginTimestamp INTEGER, + oidcData TEXT ); @@ -23,3 +24,6 @@ INSERT INTO SessionData VALUES ?; removeSession: DELETE FROM SessionData WHERE userId = ?; + +updateSession: +REPLACE INTO SessionData VALUES ?; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/2.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/2.sqm new file mode 100644 index 0000000000..9fc7f2fdaa --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/2.sqm @@ -0,0 +1 @@ +ALTER TABLE SessionData ADD COLUMN oidcData 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 fc24c5a011..e035ff9ae1 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 @@ -37,6 +37,7 @@ class DatabaseSessionStoreTests { homeserverUrl = "homeserverUrl", slidingSyncProxy = null, loginTimestamp = null, + oidcData = "aOidcData", ) @Before @@ -108,4 +109,45 @@ class DatabaseSessionStoreTests { assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() } + + @Test + fun `update session update all fields except loginTimestamp`() = runTest { + val firstSessionData = SessionData( + userId = "userId", + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + slidingSyncProxy = "slidingSyncProxy", + loginTimestamp = 1, + oidcData = "aOidcData", + ) + val secondSessionData = SessionData( + userId = "userId", + deviceId = "deviceIdAltered", + accessToken = "accessTokenAltered", + refreshToken = "refreshTokenAltered", + homeserverUrl = "homeserverUrlAltered", + slidingSyncProxy = "slidingSyncProxyAltered", + loginTimestamp = 2, + oidcData = "aOidcDataAltered", + ) + assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId) + assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp) + + database.sessionDataQueries.insertSessionData(firstSessionData) + databaseSessionStore.updateData(secondSessionData.toApiModel()) + + // Get the altered session + val alteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel() + + assertThat(alteredSession.userId).isEqualTo(secondSessionData.userId) + assertThat(alteredSession.deviceId).isEqualTo(secondSessionData.deviceId) + assertThat(alteredSession.accessToken).isEqualTo(secondSessionData.accessToken) + assertThat(alteredSession.refreshToken).isEqualTo(secondSessionData.refreshToken) + assertThat(alteredSession.homeserverUrl).isEqualTo(secondSessionData.homeserverUrl) + assertThat(alteredSession.slidingSyncProxy).isEqualTo(secondSessionData.slidingSyncProxy) + assertThat(alteredSession.loginTimestamp).isEqualTo(/* Not altered! */ firstSessionData.loginTimestamp) + assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData) + } } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 3b4c305ffc..e38c4fc6b8 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -180,11 +180,21 @@ "Setting up your account." "Additional settings" "Audio and video calls" + "Configuration mismatch" + "We’ve simplified Notifications Settings to make options easier to find. + +Some custom settings you’ve chosen in the past are not shown here, but they’re still active. + +If you proceed, some of your settings may change." "Direct chats" + "Custom setting per chat" "An error occurred while updating the notification setting." + "All messages" + "Mentions and Keywords only" "On direct chats, notify me for" "On group chats, notify me for" "Enable notifications on this device" + "The configuration has not been corrected, please try again." "Group chats" "Mentions" "All" @@ -196,6 +206,7 @@ "System notifications turned off" "Notifications" "Check if you want to hide all current and future messages from this user" + "Account and devices" "Share location" "Share my location" "Open in Apple Maps" diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/WaitingForAssertion.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/WaitingForAssertion.kt new file mode 100644 index 0000000000..6818aafc5b --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/WaitingForAssertion.kt @@ -0,0 +1,31 @@ +/* + * 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.tests.testutils + +import kotlinx.coroutines.delay + +suspend fun waitForPredicate( + delayBetweenAttemptsMillis: Long = 1, + maxNumberOfAttempts: Int = 20, + predicate: () -> Boolean, +) { + for (i in 0..maxNumberOfAttempts) { + if (predicate()) return + if (i < maxNumberOfAttempts) delay(delayBetweenAttemptsMillis) + } + throw AssertionError("Predicate was not true after $maxNumberOfAttempts attempts") +} diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_0,NEXUS_5,1.0,en].png index 9202a41eb0..d590c4eb8e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a80a93fd971e46b3a62c5e2679bfdab30cc01589398842346a2c7815af299fe -size 35413 +oid sha256:72eee76cc8244eb54f147fc589c7b200dc3a46db4ea7306dbd6757918e4fffde +size 39744 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_1,NEXUS_5,1.0,en].png index 7def318962..a3449da98e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewDark--1_1_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49d6cc10dee437df8428616173de9b94b1b80c4b9edc72057e64b6dd51fa608d -size 34701 +oid sha256:8796e5f70cdd09087ed22ede78c3aed985dcd57e073f68d83dd5884e4f29a2c8 +size 39042 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_0,NEXUS_5,1.0,en].png index 3eb39d8862..370689a2fc 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:522926ef7065fab0176b686e49cf27ad29d4fd44d18d462cb156d45332cf368d -size 37511 +oid sha256:97250f48dfa0cf2320f837eb87ad90d8a1e73642fbb2d571f326d689c4fc10e0 +size 42373 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_1,NEXUS_5,1.0,en].png index 914485e483..6bf7f8fdb0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.root_null_PreferencesRootViewLight--0_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6aaddaf0c8b536ddc231c74b6bdcb22114fca8e7445e97af91c064333873af2c -size 37614 +oid sha256:73f2811197c014d91834d39dad1fe18abdaea9b2510d8deccead39c10a1b5aa8 +size 42473