Merge pull request #1127 from vector-im/feature/bma/finishOidc

Enable OIDC support
This commit is contained in:
Benoit Marty 2023-08-23 17:01:41 +02:00 committed by GitHub
commit 14c568dea2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 488 additions and 109 deletions

1
changelog.d/1127.feature Normal file
View file

@ -0,0 +1 @@
Enable OIDC support.

View file

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

View file

@ -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<ConfirmAccountProviderState> {
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<Async<LoginFlow>>,
) {
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()
}
}

View file

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

View file

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

View file

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

View file

@ -19,6 +19,6 @@ package io.element.android.features.logout.api
import io.element.android.libraries.architecture.Async
data class LogoutPreferenceState(
val logoutAction: Async<Unit>,
val logoutAction: Async<String?>,
val eventSink: (LogoutPreferenceEvents) -> Unit,
)

View file

@ -40,7 +40,7 @@ class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixCli
@Composable
override fun present(): LogoutPreferenceState {
val localCoroutineScope = rememberCoroutineScope()
val logoutAction: MutableState<Async<Unit>> = remember {
val logoutAction: MutableState<Async<String?>> = remember {
mutableStateOf(Async.Uninitialized)
}
@ -56,7 +56,7 @@ class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixCli
)
}
private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch {
private fun CoroutineScope.logout(logoutAction: MutableState<Async<String?>>) = launch {
suspend {
matrixClient.logout()
}.runCatchingUpdatingState(logoutAction)

View file

@ -26,11 +26,13 @@
<string name="screen_room_notification_settings_default_setting_footnote">"You can change it in your %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"global settings"</string>
<string name="screen_room_notification_settings_default_setting_title">"Default setting"</string>
<string name="screen_room_notification_settings_edit_remove_setting">"Remove custom setting"</string>
<string name="screen_room_notification_settings_error_loading_settings">"An error occurred while loading notification settings."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string>
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string>
<string name="screen_room_reactions_show_less">"Show less"</string>
<string name="screen_room_reactions_show_more">"Show more"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>

View file

@ -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<Callback>().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)
}
}
}

View file

@ -68,6 +68,14 @@ class PreferencesRootPresenter @Inject constructor(
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified }
}
val accountManagementUrl: MutableState<String?> = 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<MatrixUser?>) = launch {
matrixUser.value = matrixClient.getCurrentUser()
}
private fun CoroutineScope.initAccountManagementUrl(accountManagementUrl: MutableState<String?>) = launch {
accountManagementUrl.value = matrixClient.getAccountManagementUrl().getOrNull()
}
}

View file

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

View file

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

View file

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

View file

@ -64,6 +64,7 @@ class PreferencesRootPresenterTest {
)
assertThat(loadedState.showDeveloperSettings).isEqualTo(true)
assertThat(loadedState.showAnalyticsSettings).isEqualTo(false)
assertThat(loadedState.accountManagementUrl).isNull()
}
}
}

View file

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

View file

@ -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<String>
suspend fun loadUserAvatarURLString(): Result<String?>
suspend fun getAccountManagementUrl(): Result<String?>
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
fun roomMembershipObserver(): RoomMembershipObserver

View file

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

View file

@ -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<String?> = withContext(sessionDispatcher) {
runCatching {
client.accountUrl()
}
}
override suspend fun loadUserDisplayName(): Result<String> = withContext(sessionDispatcher) {
runCatching {
client.displayName()

View file

@ -75,4 +75,5 @@ private fun SessionData.toSession() = Session(
deviceId = deviceId,
homeserverUrl = homeserverUrl,
slidingSyncProxy = slidingSyncProxy,
oidcData = oidcData,
)

View file

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

View file

@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
MatrixHomeServerDetails(
url = url(),
supportsPasswordLogin = supportsPasswordLogin(),
supportsOidcLogin = false // TODO Oidc supportsOidcLogin(),
supportsOidcLogin = supportsOidcLogin(),
)
}

View file

@ -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",
),
)
*/

View file

@ -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<MatrixHomeServerDetails?>(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<OidcDetails> {
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<Unit> {
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<SessionId> {
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(),
)

View file

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

View file

@ -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<String?> = Result.success(null),
) : MatrixClient {
private var ignoreUserResult: Result<Unit> = 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<String?> {
return accountManagementUrlString
}
override suspend fun uploadMedia(
mimeType: String,
data: ByteArray,

View file

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

View file

@ -23,6 +23,12 @@ interface SessionStore {
fun isLoggedIn(): Flow<Boolean>
fun sessionsFlow(): Flow<List<SessionData>>
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<SessionData>
suspend fun getLatestSession(): SessionData?

View file

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

View file

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

View file

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

View file

@ -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 ?;

View file

@ -0,0 +1 @@
ALTER TABLE SessionData ADD COLUMN oidcData TEXT;

View file

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

View file

@ -180,11 +180,21 @@
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
<string name="screen_notification_settings_calls_label">"Audio and video calls"</string>
<string name="screen_notification_settings_configuration_mismatch">"Configuration mismatch"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Weve simplified Notifications Settings to make options easier to find.
Some custom settings youve chosen in the past are not shown here, but theyre still active.
If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_direct_chats">"Direct chats"</string>
<string name="screen_notification_settings_edit_custom_settings_section_title">"Custom setting per chat"</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"An error occurred while updating the notification setting."</string>
<string name="screen_notification_settings_edit_mode_all_messages">"All messages"</string>
<string name="screen_notification_settings_edit_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_notification_settings_edit_screen_direct_section_header">"On direct chats, notify me for"</string>
<string name="screen_notification_settings_edit_screen_group_section_header">"On group chats, notify me for"</string>
<string name="screen_notification_settings_enable_notifications">"Enable notifications on this device"</string>
<string name="screen_notification_settings_failed_fixing_configuration">"The configuration has not been corrected, please try again."</string>
<string name="screen_notification_settings_group_chats">"Group chats"</string>
<string name="screen_notification_settings_mentions_section_title">"Mentions"</string>
<string name="screen_notification_settings_mode_all">"All"</string>
@ -196,6 +206,7 @@
<string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string>
<string name="screen_notification_settings_title">"Notifications"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_settings_oidc_account">"Account and devices"</string>
<string name="screen_share_location_title">"Share location"</string>
<string name="screen_share_my_location_action">"Share my location"</string>
<string name="screen_share_open_apple_maps">"Open in Apple Maps"</string>

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2a80a93fd971e46b3a62c5e2679bfdab30cc01589398842346a2c7815af299fe
size 35413
oid sha256:72eee76cc8244eb54f147fc589c7b200dc3a46db4ea7306dbd6757918e4fffde
size 39744

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49d6cc10dee437df8428616173de9b94b1b80c4b9edc72057e64b6dd51fa608d
size 34701
oid sha256:8796e5f70cdd09087ed22ede78c3aed985dcd57e073f68d83dd5884e4f29a2c8
size 39042

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:522926ef7065fab0176b686e49cf27ad29d4fd44d18d462cb156d45332cf368d
size 37511
oid sha256:97250f48dfa0cf2320f837eb87ad90d8a1e73642fbb2d571f326d689c4fc10e0
size 42373

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6aaddaf0c8b536ddc231c74b6bdcb22114fca8e7445e97af91c064333873af2c
size 37614
oid sha256:73f2811197c014d91834d39dad1fe18abdaea9b2510d8deccead39c10a1b5aa8
size 42473