Merge remote-tracking branch 'origin/develop' into feature/fre/improve_poll_event_timeline_rendering

This commit is contained in:
Florian Renaud 2023-08-24 14:42:16 +02:00
commit ebf6a98c34
71 changed files with 674 additions and 189 deletions

View file

@ -1,4 +1,4 @@
<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md) before submitting your pull request --> <!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
## Type of change ## Type of change
@ -17,13 +17,17 @@
## Screenshots / GIFs ## Screenshots / GIFs
<!-- Only if UI have been changed <!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.
Providing a video of the change is still very useful for the reviewer and for the history of the project.
You can use a table like this to show screenshots comparison. You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`: Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here| |copy screenshot of before here|copy screenshot of after here|
-->
<!--
|Before|After| |Before|After|
|-|-| |-|-|
||| |||
@ -47,11 +51,11 @@ Uncomment this markdown table below and edit the last line `|||`:
<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. --> <!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->
- [ ] Changes has been tested on an Android device or Android emulator with API 21 - [ ] Changes have been tested on an Android device or Android emulator with API 23
- [ ] UI change has been tested on both light and dark themes - [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#accessibility - [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch - [ ] Pull request is based on the develop branch
- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#changelog - [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] Pull request includes screenshots or videos if containing UI changes - [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off) - [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR - [ ] You've made a self review of your PR

View file

@ -1,4 +1,4 @@
# Contributing to Element Android # Contributing to Element X Android
<!--- TOC --> <!--- TOC -->

1
changelog.d/1111.bugfix Normal file
View file

@ -0,0 +1 @@
Make links in messages clickable again.

1
changelog.d/1125.bugfix Normal file
View file

@ -0,0 +1 @@
When event has no id, just cancel parsing the latest room message for a room.

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

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

1
changelog.d/1131.bugfix Normal file
View file

@ -0,0 +1 @@
Only display verification prompt after initial sync is done.

1
changelog.d/862.bugfix Normal file
View file

@ -0,0 +1 @@
Videos sent from the app were cropped in some cases.

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 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 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 package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -26,8 +27,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject 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.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError 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.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -40,7 +44,9 @@ import java.net.URL
class ConfirmAccountProviderPresenter @AssistedInject constructor( class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Assisted private val params: Params, @Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val authenticationService: MatrixAuthenticationService private val authenticationService: MatrixAuthenticationService,
private val defaultOidcActionFlow: DefaultOidcActionFlow,
private val defaultLoginUserStory: DefaultLoginUserStory,
) : Presenter<ConfirmAccountProviderState> { ) : Presenter<ConfirmAccountProviderState> {
data class Params( data class Params(
@ -61,6 +67,14 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
mutableStateOf(Async.Uninitialized) mutableStateOf(Async.Uninitialized)
} }
LaunchedEffect(Unit) {
launch {
defaultOidcActionFlow.collect {
onOidcAction(it, loginFlowAction)
}
}
}
fun handleEvents(event: ConfirmAccountProviderEvents) { fun handleEvents(event: ConfirmAccountProviderEvents) {
when (event) { when (event) {
ConfirmAccountProviderEvents.Continue -> { ConfirmAccountProviderEvents.Continue -> {
@ -97,4 +111,33 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
}.getOrThrow() }.getOrThrow()
}.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from) }.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 { object LoginConstants {
const val MATRIX_ORG_URL = "matrix.org" 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" 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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.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.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.architecture.Async 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
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC 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.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class ConfirmAccountProviderPresenterTest { class ConfirmAccountProviderPresenterTest {
@Test @Test
fun `present - initial test`() = runTest { fun `present - initial test`() = runTest {
val presenter = ConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter()
ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
AccountProviderDataSource(),
FakeAuthenticationService(),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -51,13 +52,11 @@ class ConfirmAccountProviderPresenterTest {
@Test @Test
fun `present - continue password login`() = runTest { fun `present - continue password login`() = runTest {
val authServer = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val presenter = ConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
ConfirmAccountProviderPresenter.Params(isAccountCreation = false), matrixAuthenticationService = authenticationService,
AccountProviderDataSource(),
authServer,
) )
authServer.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -75,13 +74,11 @@ class ConfirmAccountProviderPresenterTest {
@Test @Test
fun `present - continue oidc`() = runTest { fun `present - continue oidc`() = runTest {
val authServer = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val presenter = ConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
ConfirmAccountProviderPresenter.Params(isAccountCreation = false), matrixAuthenticationService = authenticationService,
AccountProviderDataSource(),
authServer,
) )
authServer.givenHomeserver(A_HOMESERVER_OIDC) authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.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 @Test
fun `present - submit fails`() = runTest { fun `present - submit fails`() = runTest {
val authServer = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val presenter = ConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
ConfirmAccountProviderPresenter.Params(isAccountCreation = false), matrixAuthenticationService = authenticationService,
AccountProviderDataSource(),
authServer,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
authServer.givenChangeServerError(Throwable()) authenticationService.givenChangeServerError(Throwable())
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading skipItems(1) // Loading
val failureState = awaitItem() val failureState = awaitItem()
@ -121,10 +234,8 @@ class ConfirmAccountProviderPresenterTest {
@Test @Test
fun `present - clear error`() = runTest { fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val presenter = ConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
ConfirmAccountProviderPresenter.Params(isAccountCreation = false), matrixAuthenticationService = authenticationService,
AccountProviderDataSource(),
authenticationService,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -147,4 +258,18 @@ class ConfirmAccountProviderPresenterTest {
assertThat(clearedState.loginFlow).isEqualTo(Async.Uninitialized) 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 @Composable
fun LogoutPreferenceView( fun LogoutPreferenceView(
state: LogoutPreferenceState, state: LogoutPreferenceState,
onSuccessLogout: () -> Unit = {} onSuccessLogout: (String?) -> Unit = {}
) { ) {
val eventSink = state.eventSink val eventSink = state.eventSink
if (state.logoutAction is Async.Success) { if (state.logoutAction is Async.Success) {
LaunchedEffect(state.logoutAction) { LaunchedEffect(state.logoutAction) {
onSuccessLogout() onSuccessLogout(state.logoutAction.data)
} }
return return
} }

View file

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

View file

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

View file

@ -66,7 +66,6 @@ fun TimelineItemTextView(
} }
ClickableLinkText( ClickableLinkText(
text = textWithPadding, text = textWithPadding,
linkAnnotationTag = "URL",
onClick = onTextClicked, onClick = onTextClicked,
onLongClick = onTextLongClicked, onLongClick = onTextLongClicked,
interactionSource = interactionSource interactionSource = interactionSource

View file

@ -582,7 +582,6 @@ private fun HtmlText(
val inlineContentMap = persistentMapOf<String, InlineTextContent>() val inlineContentMap = persistentMapOf<String, InlineTextContent>()
ClickableLinkText( ClickableLinkText(
annotatedString = text, annotatedString = text,
linkAnnotationTag = "URL",
style = style, style = style,
modifier = modifier, modifier = modifier,
inlineContent = inlineContentMap, inlineContent = inlineContentMap,

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">"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_footnote_content_link">"global settings"</string>
<string name="screen_room_notification_settings_default_setting_title">"Default setting"</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_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_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_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_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_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_less">"Show less"</string>
<string name="screen_room_reactions_show_more">"Show more"</string> <string name="screen_room_reactions_show_more">"Show more"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</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 package io.element.android.features.preferences.impl.root
import android.app.Activity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin 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.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class PreferencesRootNode @AssistedInject constructor( class PreferencesRootNode @AssistedInject constructor(
@ -62,9 +66,16 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenAbout() } plugins<Callback>().forEach { it.onOpenAbout() }
} }
private fun onManageAccountClicked(activity: Activity, accountManagementUrl: String?) {
accountManagementUrl?.let {
activity.openUrlInChromeCustomTab(null, false, it)
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val activity = LocalContext.current as Activity
PreferencesRootView( PreferencesRootView(
state = state, state = state,
modifier = modifier, modifier = modifier,
@ -73,7 +84,16 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenAnalytics = this::onOpenAnalytics, onOpenAnalytics = this::onOpenAnalytics,
onOpenAbout = this::onOpenAbout, onOpenAbout = this::onOpenAbout,
onVerifyClicked = this::onVerifyClicked, 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

@ -20,7 +20,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -34,7 +33,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -62,10 +60,15 @@ class PreferencesRootPresenter @Inject constructor(
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() } val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() }
// Session verification status (unknown, not verified, verified) // We should display the 'complete verification' option if the current session can be verified
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
val sessionIsNotVerified by remember {
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified } val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
LaunchedEffect(Unit) {
initAccountManagementUrl(accountManagementUrl)
} }
val logoutState = logoutPresenter.present() val logoutState = logoutPresenter.present()
@ -74,7 +77,8 @@ class PreferencesRootPresenter @Inject constructor(
logoutState = logoutState, logoutState = logoutState,
myUser = matrixUser.value, myUser = matrixUser.value,
version = versionFormatter.get(), version = versionFormatter.get(),
showCompleteVerification = sessionIsNotVerified, showCompleteVerification = showCompleteVerification,
accountManagementUrl = accountManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders, showAnalyticsSettings = hasAnalyticsProviders,
showDeveloperSettings = showDeveloperSettings, showDeveloperSettings = showDeveloperSettings,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
@ -84,4 +88,8 @@ class PreferencesRootPresenter @Inject constructor(
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch { private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
matrixUser.value = matrixClient.getCurrentUser() 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 myUser: MatrixUser?,
val version: String, val version: String,
val showCompleteVerification: Boolean, val showCompleteVerification: Boolean,
val accountManagementUrl: String?,
val showAnalyticsSettings: Boolean, val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,

View file

@ -25,6 +25,7 @@ fun aPreferencesRootState() = PreferencesRootState(
myUser = null, myUser = null,
version = "Version 1.1 (1)", version = "Version 1.1 (1)",
showCompleteVerification = true, showCompleteVerification = true,
accountManagementUrl = "aUrl",
showAnalyticsSettings = true, showAnalyticsSettings = true,
showDeveloperSettings = true, showDeveloperSettings = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), 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.DeveloperMode
import androidx.compose.material.icons.outlined.Help import androidx.compose.material.icons.outlined.Help
import androidx.compose.material.icons.outlined.InsertChart import androidx.compose.material.icons.outlined.InsertChart
import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -51,10 +52,12 @@ fun PreferencesRootView(
state: PreferencesRootState, state: PreferencesRootState,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onVerifyClicked: () -> Unit, onVerifyClicked: () -> Unit,
onManageAccountClicked: () -> Unit,
onOpenAnalytics: () -> Unit, onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit, onOpenRageShake: () -> Unit,
onOpenAbout: () -> Unit, onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit,
onSuccessLogout: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -75,6 +78,13 @@ fun PreferencesRootView(
) )
HorizontalDivider() HorizontalDivider()
} }
if (state.accountManagementUrl != null) {
PreferenceText(
title = stringResource(id = CommonStrings.screen_settings_oidc_account),
icon = Icons.Outlined.ManageAccounts,
onClick = onManageAccountClicked,
)
}
if (state.showAnalyticsSettings) { if (state.showAnalyticsSettings) {
PreferenceText( PreferenceText(
title = stringResource(id = CommonStrings.common_analytics), title = stringResource(id = CommonStrings.common_analytics),
@ -98,6 +108,7 @@ fun PreferencesRootView(
HorizontalDivider() HorizontalDivider()
LogoutPreferenceView( LogoutPreferenceView(
state = state.logoutState, state = state.logoutState,
onSuccessLogout = onSuccessLogout,
) )
Text( Text(
modifier = Modifier modifier = Modifier
@ -140,5 +151,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenDeveloperSettings = {}, onOpenDeveloperSettings = {},
onOpenAbout = {}, onOpenAbout = {},
onVerifyClicked = {}, onVerifyClicked = {},
onSuccessLogout = {},
onManageAccountClicked = {},
) )
} }

View file

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

View file

@ -39,7 +39,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -73,11 +72,11 @@ class RoomListPresenter @Inject constructor(
} }
// Session verification status (unknown, not verified, verified) // Session verification status (unknown, not verified, verified)
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) } var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) }
// We combine both values to only display the prompt if the session is not verified and it wasn't dismissed // We combine both values to only display the prompt if the session is not verified and it wasn't dismissed
val displayVerificationPrompt by remember { val displayVerificationPrompt by remember {
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed } derivedStateOf { canVerifySession && !verificationPromptDismissed }
} }
var displaySearchResults by rememberSaveable { mutableStateOf(false) } var displaySearchResults by rememberSaveable { mutableStateOf(false) }

View file

@ -202,7 +202,7 @@ class RoomListPresenterTests {
fun `present - handle DismissRequestVerificationPrompt`() = runTest { fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService,
) )
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,

View file

@ -37,7 +37,7 @@ test_core = "1.5.0"
#other #other
coil = "2.4.0" coil = "2.4.0"
datetime = "0.4.0" datetime = "0.4.0"
serialization_json = "1.5.1" serialization_json = "1.6.0"
showkase = "1.0.0-beta18" showkase = "1.0.0-beta18"
jsoup = "1.16.1" jsoup = "1.16.1"
appyx = "1.3.0" appyx = "1.3.0"
@ -92,7 +92,7 @@ androidx_startup = { module = "androidx.startup:startup-runtime", version.ref =
androidx_preference = "androidx.preference:preference:1.2.1" androidx_preference = "androidx.preference:preference:1.2.1"
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
androidx_compose_material3 = "androidx.compose.material3:material3:1.2.0-alpha05" androidx_compose_material3 = "androidx.compose.material3:material3:1.2.0-alpha06"
# Coroutines # Coroutines
coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
@ -146,7 +146,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" 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.47"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } 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-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

View file

@ -48,13 +48,15 @@ import io.element.android.libraries.theme.LinkColor
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
const val LINK_TAG = "URL"
@Composable @Composable
fun ClickableLinkText( fun ClickableLinkText(
text: String, text: String,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
linkify: Boolean = true, linkify: Boolean = true,
linkAnnotationTag: String = "", linkAnnotationTag: String = LINK_TAG,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current, style: TextStyle = LocalTextStyle.current,
@ -80,13 +82,14 @@ fun ClickableLinkText(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
linkify: Boolean = true, linkify: Boolean = true,
linkAnnotationTag: String = "", linkAnnotationTag: String = LINK_TAG,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current, style: TextStyle = LocalTextStyle.current,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(), inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
) { ) {
val processedText = remember(annotatedString) { @Suppress("NAME_SHADOWING")
val annotatedString = remember(annotatedString) {
if (linkify) { if (linkify) {
annotatedString.linkify(SpanStyle(color = LinkColor)) annotatedString.linkify(SpanStyle(color = LinkColor))
} else { } else {
@ -126,7 +129,7 @@ fun ClickableLinkText(
} }
} }
Text( Text(
text = processedText, text = annotatedString,
modifier = modifier.then(pressIndicator), modifier = modifier.then(pressIndicator),
style = style, style = style,
onTextLayout = { onTextLayout = {
@ -158,7 +161,7 @@ fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
style = linkStyle, style = linkStyle,
) )
addStringAnnotation( addStringAnnotation(
tag = "URL", tag = LINK_TAG,
annotation = span.url, annotation = span.url,
start = start, start = start,
end = end end = end

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -133,32 +134,46 @@ internal fun ButtonInternal(
ButtonSize.Large -> 48.dp ButtonSize.Large -> 48.dp
} }
val hasStartDrawable = showProgress || leadingIcon != null
val contentPadding = when (size) { val contentPadding = when (size) {
ButtonSize.Medium -> { ButtonSize.Medium -> when (style) {
when (style) { ButtonStyle.Filled,
ButtonStyle.Text -> PaddingValues(horizontal = 12.dp, vertical = 10.dp) ButtonStyle.Outlined -> if (hasStartDrawable)
else -> PaddingValues(horizontal = 16.dp, vertical = 10.dp) PaddingValues(start = 16.dp, top = 10.dp, end = 24.dp, bottom = 10.dp)
} else
PaddingValues(start = 24.dp, top = 10.dp, end = 24.dp, bottom = 10.dp)
ButtonStyle.Text -> if (hasStartDrawable)
PaddingValues(start = 12.dp, top = 10.dp, end = 16.dp, bottom = 10.dp)
else
PaddingValues(start = 12.dp, top = 10.dp, end = 12.dp, bottom = 10.dp)
} }
ButtonSize.Large -> { ButtonSize.Large -> when (style) {
when (style) { ButtonStyle.Filled,
ButtonStyle.Text -> PaddingValues(horizontal = 16.dp, vertical = 13.dp) ButtonStyle.Outlined -> if (hasStartDrawable)
else -> PaddingValues(horizontal = 24.dp, vertical = 13.dp) PaddingValues(start = 24.dp, top = 13.dp, end = 32.dp, bottom = 13.dp)
} else
PaddingValues(start = 32.dp, top = 13.dp, end = 32.dp, bottom = 13.dp)
ButtonStyle.Text -> if (hasStartDrawable)
PaddingValues(start = 12.dp, top = 13.dp, end = 16.dp, bottom = 13.dp)
else
PaddingValues(start = 16.dp, top = 13.dp, end = 16.dp, bottom = 13.dp)
} }
} }
val shape = when (style) { val shape = when (style) {
ButtonStyle.Filled, ButtonStyle.Outlined -> RoundedCornerShape(percent = 50) ButtonStyle.Filled,
ButtonStyle.Outlined -> RoundedCornerShape(percent = 50)
ButtonStyle.Text -> RectangleShape ButtonStyle.Text -> RectangleShape
} }
val border = when (style) { val border = when (style) {
ButtonStyle.Filled, ButtonStyle.Text -> null ButtonStyle.Filled -> null
ButtonStyle.Outlined -> BorderStroke( ButtonStyle.Outlined -> BorderStroke(
width = 1.dp, width = 1.dp,
color = ElementTheme.colors.borderInteractiveSecondary color = ElementTheme.colors.borderInteractiveSecondary
) )
ButtonStyle.Text -> null
} }
val textStyle = when (size) { val textStyle = when (size) {
@ -166,11 +181,6 @@ internal fun ButtonInternal(
ButtonSize.Large -> ElementTheme.typography.fontBodyLgMedium ButtonSize.Large -> ElementTheme.typography.fontBodyLgMedium
} }
val internalPadding = when {
style == ButtonStyle.Text -> if (leadingIcon != null) PaddingValues(start = 8.dp) else PaddingValues(0.dp)
else -> PaddingValues(horizontal = 8.dp)
}
androidx.compose.material3.Button( androidx.compose.material3.Button(
onClick = { onClick = {
if (!showProgress) { if (!showProgress) {
@ -195,6 +205,7 @@ internal fun ButtonInternal(
color = LocalContentColor.current, color = LocalContentColor.current,
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
Spacer(modifier = Modifier.width(8.dp))
} }
leadingIcon != null -> { leadingIcon != null -> {
androidx.compose.material.Icon( androidx.compose.material.Icon(
@ -203,15 +214,14 @@ internal fun ButtonInternal(
tint = LocalContentColor.current, tint = LocalContentColor.current,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
Spacer(modifier = Modifier.width(8.dp))
} }
else -> Unit
} }
Text( Text(
text = text, text = text,
style = textStyle, style = textStyle,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(internalPadding),
) )
} }
} }

View file

@ -55,9 +55,15 @@ interface MatrixClient : Closeable {
* Will close the client and delete the cache data. * Will close the client and delete the cache data.
*/ */
suspend fun clearCache() 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 loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?> suspend fun loadUserAvatarURLString(): Result<String?>
suspend fun getAccountManagementUrl(): Result<String?>
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
fun roomMembershipObserver(): RoomMembershipObserver fun roomMembershipObserver(): RoomMembershipObserver

View file

@ -22,6 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) {
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message) class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
class SessionMissing(message: String) : AuthenticationException(message) class SessionMissing(message: String) : AuthenticationException(message)
class Generic(message: String) : AuthenticationException(message) class Generic(message: String) : AuthenticationException(message)
// TODO Oidc data class OidcError(val type: String, override val message: String) : AuthenticationException(message)
// class OidcError(type: String, message: String) : AuthenticationException(message)
} }

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -141,6 +142,21 @@ interface MatrixRoom : Closeable {
assetType: AssetType? = null, assetType: AssetType? = null,
): Result<Unit> ): Result<Unit>
/**
* Create a poll in the room.
*
* @param question The question to ask.
* @param answers The list of answers.
* @param maxSelections The maximum number of answers that can be selected.
* @param pollKind The kind of poll to create.
*/
suspend fun createPoll(
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit>
override fun close() = destroy() override fun close() = destroy()
} }

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.api.verification package io.element.android.libraries.matrix.api.verification
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface SessionVerificationService { interface SessionVerificationService {
@ -37,6 +38,11 @@ interface SessionVerificationService {
*/ */
val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus>
/**
* Returns whether the current session needs to be verified and the SDK is ready to start the verification.
*/
val canVerifySessionFlow: Flow<Boolean>
/** /**
* Request verification of the current session. * Request verification of the current session.
*/ */

View file

@ -35,11 +35,11 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults 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.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService 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.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.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.notification.RustNotificationService
import io.element.android.libraries.matrix.impl.pushers.RustPushersService import io.element.android.libraries.matrix.impl.pushers.RustPushersService
@ -92,8 +92,8 @@ class RustMatrixClient constructor(
private val innerRoomListService = syncService.roomListService() private val innerRoomListService = syncService.roomListService()
private val sessionDispatcher = dispatchers.io.limitedParallelism(64) private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-${sessionId}") private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-${sessionId}")
private val verificationService = RustSessionVerificationService()
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope) private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
private val verificationService = RustSessionVerificationService(rustSyncService)
private val pushersService = RustPushersService( private val pushersService = RustPushersService(
client = client, client = client,
dispatchers = dispatchers, dispatchers = dispatchers,
@ -119,6 +119,13 @@ class RustMatrixClient constructor(
Timber.v("didReceiveAuthError -> already cleaning up") Timber.v("didReceiveAuthError -> already cleaning up")
} }
} }
override fun didRefreshTokens() {
Timber.w("didRefreshTokens()")
appCoroutineScope.launch {
sessionStore.updateData(client.session().toSessionData())
}
}
} }
private val rustRoomListService: RoomListService = private val rustRoomListService: RoomListService =
@ -141,13 +148,11 @@ class RustMatrixClient constructor(
init { init {
client.setDelegate(clientDelegate) client.setDelegate(clientDelegate)
rustSyncService.syncState roomListService.state.onEach { state ->
.onEach { syncState -> if (state == RoomListService.State.Running) {
if (syncState == SyncState.Running) { setupVerificationControllerIfNeeded()
onSlidingSyncUpdate()
}
} }
.launchIn(sessionCoroutineScope) }.launchIn(sessionCoroutineScope)
} }
override suspend fun getRoom(roomId: RoomId): MatrixRoom? = withContext(sessionDispatcher) { override suspend fun getRoom(roomId: RoomId): MatrixRoom? = withContext(sessionDispatcher) {
@ -287,21 +292,30 @@ class RustMatrixClient constructor(
baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false) 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) { private suspend fun doLogout(doRequest: Boolean): String? {
if (doRequest) { var result: String? = null
try { withContext(sessionDispatcher) {
client.logout() if (doRequest) {
} catch (failure: Throwable) { try {
Timber.e(failure, "Fail to call logout on HS. Still delete local files.") 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() return result
baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true)
sessionStore.removeSession(sessionId.value)
} }
override suspend fun getAccountManagementUrl(): Result<String?> = withContext(sessionDispatcher) {
runCatching {
client.accountUrl()
}
}
override suspend fun loadUserDisplayName(): Result<String> = withContext(sessionDispatcher) { override suspend fun loadUserDisplayName(): Result<String> = withContext(sessionDispatcher) {
runCatching { runCatching {
client.displayName() client.displayName()
@ -321,8 +335,8 @@ class RustMatrixClient constructor(
} }
} }
private fun onSlidingSyncUpdate() { private fun setupVerificationControllerIfNeeded() {
if (!verificationService.isReady.value) { if (verificationService.verificationController == null) {
try { try {
verificationService.verificationController = client.getSessionVerificationController() verificationService.verificationController = client.getSessionVerificationController()
} catch (e: Throwable) { } catch (e: Throwable) {

View file

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

View file

@ -26,15 +26,12 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!) is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!) is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!) is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!)
/* TODO Oidc
is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!) is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!)
is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!) is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!)
is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!) is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!)
is RustAuthenticationException.OidcNotStarted -> AuthenticationException.OidcError("OidcNotStarted", message!!)
is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", 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") else -> AuthenticationException.Generic(this.message ?: "Unknown error")
} }
} }

View file

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

View file

@ -16,17 +16,19 @@
package io.element.android.libraries.matrix.impl.auth package io.element.android.libraries.matrix.impl.auth
// TODO Oidc import io.element.android.libraries.matrix.api.auth.OidcConfig
// import io.element.android.libraries.matrix.api.auth.OidcConfig import org.matrix.rustcomponents.sdk.OidcConfiguration
// import org.matrix.rustcomponents.sdk.OidcClientMetadata
/* val oidcConfiguration: OidcConfiguration = OidcConfiguration(
val oidcClientMetadata: OidcClientMetadata = OidcClientMetadata(
clientName = "Element", clientName = "Element",
redirectUri = OidcConfig.redirectUri, redirectUri = OidcConfig.redirectUri,
clientUri = "https://element.io", clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service", 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 package io.element.android.libraries.matrix.impl.auth
// TODO Oidc
// import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure 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.api.core.SessionId
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.exception.mapClientException 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.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Session import org.matrix.rustcomponents.sdk.OidcAuthenticationData
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import java.io.File import java.io.File
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService
@ -57,9 +54,8 @@ class RustMatrixAuthenticationService @Inject constructor(
private val authService: RustAuthenticationService = RustAuthenticationService( private val authService: RustAuthenticationService = RustAuthenticationService(
basePath = baseDirectory.absolutePath, basePath = baseDirectory.absolutePath,
passphrase = null, passphrase = null,
// TODO Oidc
// oidcClientMetadata = oidcClientMetadata,
userAgent = userAgentProvider.provide(), userAgent = userAgentProvider.provide(),
oidcConfiguration = oidcConfiguration,
customSlidingSyncProxy = null, customSlidingSyncProxy = null,
) )
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null) private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
@ -112,68 +108,48 @@ class RustMatrixAuthenticationService @Inject constructor(
} }
} }
// TODO Oidc private var pendingOidcAuthenticationData: OidcAuthenticationData? = null
// private var pendingUrlForOidcLogin: OidcAuthenticationUrl? = null
override suspend fun getOidcUrl(): Result<OidcDetails> { override suspend fun getOidcUrl(): Result<OidcDetails> {
TODO("Oidc")
/*
return withContext(coroutineDispatchers.io) { return withContext(coroutineDispatchers.io) {
runCatching { runCatching {
val urlForOidcLogin = authService.urlForOidcLogin() val oidcAuthenticationData = authService.urlForOidcLogin()
val url = urlForOidcLogin.loginUrl() val url = oidcAuthenticationData.loginUrl()
pendingUrlForOidcLogin = urlForOidcLogin pendingOidcAuthenticationData = oidcAuthenticationData
OidcDetails(url) OidcDetails(url)
}.mapFailure { failure -> }.mapFailure { failure ->
failure.mapAuthenticationException() failure.mapAuthenticationException()
} }
} }
*/
} }
override suspend fun cancelOidcLogin(): Result<Unit> { override suspend fun cancelOidcLogin(): Result<Unit> {
TODO("Oidc")
/*
return withContext(coroutineDispatchers.io) { return withContext(coroutineDispatchers.io) {
runCatching { runCatching {
pendingUrlForOidcLogin?.close() pendingOidcAuthenticationData?.close()
pendingUrlForOidcLogin = null pendingOidcAuthenticationData = null
}.mapFailure { failure -> }.mapFailure { failure ->
failure.mapAuthenticationException() failure.mapAuthenticationException()
} }
} }
*/
} }
/** /**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters). * callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/ */
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> { override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
TODO("Oidc")
/*
return withContext(coroutineDispatchers.io) { return withContext(coroutineDispatchers.io) {
runCatching { 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 client = authService.loginWithOidcCallback(urlForOidcLogin, callbackUrl)
val sessionData = client.use { it.session().toSessionData() } val sessionData = client.use { it.session().toSessionData() }
pendingUrlForOidcLogin = null pendingOidcAuthenticationData?.close()
pendingOidcAuthenticationData = null
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
SessionId(sessionData.userId) SessionId(sessionData.userId)
}.mapFailure { failure -> }.mapFailure { failure ->
failure.mapAuthenticationException() 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

@ -23,3 +23,8 @@ fun RustPollKind.map(): PollKind = when (this) {
RustPollKind.DISCLOSED -> PollKind.Disclosed RustPollKind.DISCLOSED -> PollKind.Disclosed
RustPollKind.UNDISCLOSED -> PollKind.Undisclosed RustPollKind.UNDISCLOSED -> PollKind.Undisclosed
} }
fun PollKind.toInner(): RustPollKind = when (this) {
PollKind.Disclosed -> RustPollKind.DISCLOSED
PollKind.Undisclosed -> RustPollKind.UNDISCLOSED
}

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
@ -41,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.impl.core.toProgressWatcher import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.util.destroyAll import io.element.android.libraries.matrix.impl.util.destroyAll
@ -378,7 +380,24 @@ class RustMatrixRoom(
description = description, description = description,
zoomLevel = zoomLevel?.toUByte(), zoomLevel = zoomLevel?.toUByte(),
assetType = assetType?.toInner(), assetType = assetType?.toInner(),
txnId = genTransactionId() txnId = genTransactionId(),
)
}
}
override suspend fun createPoll(
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections.toUByte(),
pollKind = pollKind.toInner(),
txnId = genTransactionId(),
) )
} }
} }

View file

@ -25,7 +25,7 @@ class RoomMessageFactory {
eventTimelineItem ?: return null eventTimelineItem ?: return null
val mappedTimelineItem = EventTimelineItemMapper().map(eventTimelineItem) val mappedTimelineItem = EventTimelineItemMapper().map(eventTimelineItem)
return RoomMessage( return RoomMessage(
eventId = mappedTimelineItem.eventId!!, eventId = mappedTimelineItem.eventId ?: return null,
event = mappedTimelineItem, event = mappedTimelineItem,
sender = mappedTimelineItem.sender, sender = mappedTimelineItem.sender,
originServerTs = mappedTimelineItem.timestamp, originServerTs = mappedTimelineItem.timestamp,

View file

@ -17,20 +17,25 @@
package io.element.android.libraries.matrix.impl.verification package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import org.matrix.rustcomponents.sdk.SessionVerificationController import org.matrix.rustcomponents.sdk.SessionVerificationController
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
import org.matrix.rustcomponents.sdk.SessionVerificationEmoji import org.matrix.rustcomponents.sdk.SessionVerificationEmoji
import javax.inject.Inject import javax.inject.Inject
class RustSessionVerificationService @Inject constructor() : SessionVerificationService, SessionVerificationControllerDelegate { class RustSessionVerificationService @Inject constructor(
private val syncService: RustSyncService,
) : SessionVerificationService, SessionVerificationControllerDelegate {
var verificationController: SessionVerificationControllerInterface? = null var verificationController: SessionVerificationControllerInterface? = null
set(value) { set(value) {
@ -52,6 +57,10 @@ class RustSessionVerificationService @Inject constructor() : SessionVerification
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow() override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
override val canVerifySessionFlow = combine(sessionVerifiedStatus, syncService.syncState) { verificationStatus, syncState ->
syncState == SyncState.Running && verificationStatus == SessionVerifiedStatus.NotVerified
}
override suspend fun requestVerification() = tryOrFail { override suspend fun requestVerification() = tryOrFail {
verificationController?.requestVerification() verificationController?.requestVerification()
} }

View file

@ -51,6 +51,7 @@ class FakeMatrixClient(
private val pushersService: FakePushersService = FakePushersService(), private val pushersService: FakePushersService = FakePushersService(),
private val notificationService: FakeNotificationService = FakeNotificationService(), private val notificationService: FakeNotificationService = FakeNotificationService(),
private val syncService: FakeSyncService = FakeSyncService(), private val syncService: FakeSyncService = FakeSyncService(),
private val accountManagementUrlString: Result<String?> = Result.success(null),
) : MatrixClient { ) : MatrixClient {
private var ignoreUserResult: Result<Unit> = Result.success(Unit) private var ignoreUserResult: Result<Unit> = Result.success(Unit)
@ -109,9 +110,10 @@ class FakeMatrixClient(
override suspend fun clearCache() { override suspend fun clearCache() {
} }
override suspend fun logout() { override suspend fun logout(): String? {
delay(100) delay(100)
logoutFailure?.let { throw it } logoutFailure?.let { throw it }
return null
} }
override fun close() = Unit override fun close() = Unit
@ -124,6 +126,9 @@ class FakeMatrixClient(
return userAvatarURLString return userAvatarURLString
} }
override suspend fun getAccountManagementUrl(): Result<String?> {
return accountManagementUrlString
}
override suspend fun uploadMedia( override suspend fun uploadMedia(
mimeType: String, mimeType: String,
data: ByteArray, data: ByteArray,

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
@ -83,6 +84,7 @@ class FakeMatrixRoom(
private var forwardEventResult = Result.success(Unit) private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit) private var reportContentResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit) private var sendLocationResult = Result.success(Unit)
private var createPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>() private var progressCallbackValues = emptyList<Pair<Long, Long>>()
val editMessageCalls = mutableListOf<String>() val editMessageCalls = mutableListOf<String>()
@ -104,6 +106,9 @@ class FakeMatrixRoom(
private val _sentLocations = mutableListOf<SendLocationInvocation>() private val _sentLocations = mutableListOf<SendLocationInvocation>()
val sentLocations: List<SendLocationInvocation> = _sentLocations val sentLocations: List<SendLocationInvocation> = _sentLocations
private val _createPollInvocations = mutableListOf<CreatePollInvocation>()
val createPollInvocations: List<CreatePollInvocation> = _createPollInvocations
var invitedUserId: UserId? = null var invitedUserId: UserId? = null
private set private set
@ -305,6 +310,16 @@ class FakeMatrixRoom(
return sendLocationResult return sendLocationResult
} }
override suspend fun createPoll(
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind
): Result<Unit> = simulateLongTask {
_createPollInvocations.add(CreatePollInvocation(question, answers, maxSelections, pollKind))
return createPollResult
}
fun givenLeaveRoomError(throwable: Throwable?) { fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable this.leaveRoomError = throwable
} }
@ -397,6 +412,10 @@ class FakeMatrixRoom(
sendLocationResult = result sendLocationResult = result
} }
fun givenCreatePollResult(result: Result<Unit>) {
createPollResult = result
}
fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) { fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) {
progressCallbackValues = values progressCallbackValues = values
} }
@ -409,3 +428,10 @@ data class SendLocationInvocation(
val zoomLevel: Int?, val zoomLevel: Int?,
val assetType: AssetType?, val assetType: AssetType?,
) )
data class CreatePollInvocation(
val question: String,
val answers: List<String>,
val maxSelections: Int,
val pollKind: PollKind,
)

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -27,13 +28,13 @@ class FakeSessionVerificationService : SessionVerificationService {
private val _isReady = MutableStateFlow(false) private val _isReady = MutableStateFlow(false)
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial) private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var _canVerifySessionFlow = MutableStateFlow(true)
private var emojiList = emptyList<VerificationEmoji>() private var emojiList = emptyList<VerificationEmoji>()
var shouldFail = false var shouldFail = false
override val verificationFlowState: StateFlow<VerificationFlowState> override val verificationFlowState: StateFlow<VerificationFlowState> =_verificationFlowState
get() = _verificationFlowState
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val canVerifySessionFlow: Flow<Boolean> = _canVerifySessionFlow
override val isReady: StateFlow<Boolean> = _isReady override val isReady: StateFlow<Boolean> = _isReady
@ -77,6 +78,10 @@ class FakeSessionVerificationService : SessionVerificationService {
_verificationFlowState.value = state _verificationFlowState.value = state
} }
fun givenCanVerifySession(canVerify: Boolean) {
_canVerifySessionFlow.value = canVerify
}
fun givenIsReady(value: Boolean) { fun givenIsReady(value: Boolean) {
_isReady.value = value _isReady.value = value
} }

View file

@ -20,6 +20,8 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import com.otaliastudios.transcoder.Transcoder import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.resize.AtMostResizer
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import io.element.android.libraries.androidutils.file.createTmpFile import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
@ -35,6 +37,11 @@ class VideoCompressor @Inject constructor(
fun compress(uri: Uri) = callbackFlow { fun compress(uri: Uri) = callbackFlow {
val tmpFile = context.createTmpFile(extension = "mp4") val tmpFile = context.createTmpFile(extension = "mp4")
val future = Transcoder.into(tmpFile.path) val future = Transcoder.into(tmpFile.path)
.setVideoTrackStrategy(
DefaultVideoStrategy.Builder()
.addResizer(AtMostResizer(1920, 1080))
.build()
)
.addDataSource(context, uri) .addDataSource(context, uri)
.setListener(object : TranscoderListener { .setListener(object : TranscoderListener {
override fun onTranscodeProgress(progress: Double) { override fun onTranscodeProgress(progress: Double) {

View file

@ -24,6 +24,7 @@ data class SessionData(
val accessToken: String, val accessToken: String,
val refreshToken: String?, val refreshToken: String?,
val homeserverUrl: String, val homeserverUrl: String,
val oidcData: String?,
val slidingSyncProxy: String?, val slidingSyncProxy: String?,
val loginTimestamp: Date?, val loginTimestamp: Date?,
) )

View file

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

View file

@ -38,6 +38,10 @@ class InMemorySessionStore : SessionStore {
sessionDataFlow.value = sessionData sessionDataFlow.value = sessionData
} }
override suspend fun updateData(sessionData: SessionData) {
sessionDataFlow.value = sessionData
}
override suspend fun getSession(sessionId: String): SessionData? { override suspend fun getSession(sessionId: String): SessionData? {
return sessionDataFlow.value.takeIf { it?.userId == sessionId } return sessionDataFlow.value.takeIf { it?.userId == sessionId }
} }

View file

@ -46,6 +46,24 @@ class DatabaseSessionStore @Inject constructor(
database.sessionDataQueries.insertSessionData(sessionData.toDbModel()) 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? { override suspend fun getLatestSession(): SessionData? {
return database.sessionDataQueries.selectFirst() return database.sessionDataQueries.selectFirst()
.executeAsOneOrNull() .executeAsOneOrNull()

View file

@ -27,6 +27,7 @@ internal fun SessionData.toDbModel(): DbSessionData {
accessToken = accessToken, accessToken = accessToken,
refreshToken = refreshToken, refreshToken = refreshToken,
homeserverUrl = homeserverUrl, homeserverUrl = homeserverUrl,
oidcData = oidcData,
slidingSyncProxy = slidingSyncProxy, slidingSyncProxy = slidingSyncProxy,
loginTimestamp = loginTimestamp?.time, loginTimestamp = loginTimestamp?.time,
) )
@ -39,6 +40,7 @@ internal fun DbSessionData.toApiModel(): SessionData {
accessToken = accessToken, accessToken = accessToken,
refreshToken = refreshToken, refreshToken = refreshToken,
homeserverUrl = homeserverUrl, homeserverUrl = homeserverUrl,
oidcData = oidcData,
slidingSyncProxy = slidingSyncProxy, slidingSyncProxy = slidingSyncProxy,
loginTimestamp = loginTimestamp?.let { Date(it) } loginTimestamp = loginTimestamp?.let { Date(it) }
) )

View file

@ -5,7 +5,8 @@ CREATE TABLE SessionData (
refreshToken TEXT, refreshToken TEXT,
homeserverUrl TEXT NOT NULL, homeserverUrl TEXT NOT NULL,
slidingSyncProxy TEXT, slidingSyncProxy TEXT,
loginTimestamp INTEGER loginTimestamp INTEGER,
oidcData TEXT
); );
@ -23,3 +24,6 @@ INSERT INTO SessionData VALUES ?;
removeSession: removeSession:
DELETE FROM SessionData WHERE userId = ?; 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", homeserverUrl = "homeserverUrl",
slidingSyncProxy = null, slidingSyncProxy = null,
loginTimestamp = null, loginTimestamp = null,
oidcData = "aOidcData",
) )
@Before @Before
@ -108,4 +109,45 @@ class DatabaseSessionStoreTests {
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() 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_migration_title">"Setting up your account."</string>
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</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_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_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_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_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_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_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_group_chats">"Group chats"</string>
<string name="screen_notification_settings_mentions_section_title">"Mentions"</string> <string name="screen_notification_settings_mentions_section_title">"Mentions"</string>
<string name="screen_notification_settings_mode_all">"All"</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_system_notifications_turned_off">"System notifications turned off"</string>
<string name="screen_notification_settings_title">"Notifications"</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_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_location_title">"Share location"</string>
<string name="screen_share_my_location_action">"Share my 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> <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 version https://git-lfs.github.com/spec/v1
oid sha256:1c0cac586f62b725e9377111e3c2d39001048aebfd1f8fd113ea3e55d16d781c oid sha256:6e902df5e96079c03fbbd36b79791c90f637ab29f4b2a2aa6df7981e09d93ee0
size 316907 size 316906

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8a8aab4a7fc06146668156c43424446d03ef8efb1c0ce1fd2105d45ced3e96d9 oid sha256:4de11f9a3d8a0165607960b926a2c1b14b6d0561ae52cbf504543bf0692fae09
size 310355 size 310356

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9b571751a70c761f4a460e0b486bd519faf30b08ec9ac208939d6690bc115874 oid sha256:0630d924f2bd374f8bd53ad41344b8d66ead984ce4bc0b55d455752d8d5409d9
size 405239 size 405238

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f0d500141b3cdf3410fddce876ee4c68d8ece26e40e34bd094cf95a7985ff199 oid sha256:77f91c56ce019ea31d618e63a5cba1c09d43140fd8b92458de2477e95a70ea7f
size 392950 size 392950

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:02627b5036d3d937142e0086f5a50871fc760b787416e23f8b54e046c377060d oid sha256:abe1134e30761c5ec6815c27568f0238b12f42c32746c69e428be477f28cc203
size 10251 size 10233

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ba3088e305d112ab0265c5d18005556c522f0b705bd2c547c23ebdd0b25c04ca oid sha256:162a11f98fc16c2cf2de9380594bb29893c067479f5c9b2405b66b58474ae8c7
size 9849 size 9850

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f49f70bf1c036ad801002094642773cb569025d2d2eedb27a462dd3405dc0262 oid sha256:c6ac3ab7ac24942fca51adfc3cc8bf5ad797e912baf835baa73e8289b6d4a94d
size 28348 size 28308

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:83ae98e86b9e229c82e66e495ce9130f8aac3fd3364479409dd8aec2f60f5c44 oid sha256:d8233ab7e58175a8dbd14aa8c30dda882176099232cd56417102fa3675efe92b
size 32430 size 31114

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8e9ce0aafcaa2c873d706c6e477f2103c8f930f0c15e186a981eaa831e51bfc oid sha256:974dd693b78cc08e278ff81a65e234629cdbf1075d3fbf255895cf1ee2eb906c
size 30609 size 29264

View file

@ -25,6 +25,9 @@ baseAction = {
# Replacement done in all string values # Replacement done in all string values
"replacements": { "replacements": {
"...": "" "...": ""
},
"params": {
"force_underscore": "yes"
} }
} }