Merge branch 'release/0.6.1' into main

This commit is contained in:
Benoit Marty 2024-09-17 14:00:40 +02:00
commit da57b0416b
307 changed files with 4446 additions and 1191 deletions

View file

@ -1,3 +1,22 @@
Changes in Element X v0.6.0 (2024-09-12)
========================================
### 🙌 Improvements
* Enables pinned messages feature by default. by @ganfra in https://github.com/element-hq/element-x-android/pull/3439
* Pinned messages list : hide reactions by @ganfra in https://github.com/element-hq/element-x-android/pull/3430
### 🐛 Bugfixes
* Feature/fga/pinned messages fix timeline provider by @ganfra in https://github.com/element-hq/element-x-android/pull/3432
### Dependency upgrades
* Update activity to v1.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3397
* Update peter-evans/create-pull-request action to v7 by @renovate in https://github.com/element-hq/element-x-android/pull/3383
* Rust sdk upgrade to 0.2.43 by @bmarty in https://github.com/element-hq/element-x-android/pull/3446
### Others
* DeviceId and cleanup. by @bmarty in https://github.com/element-hq/element-x-android/pull/3442
* Update application store assets by @bmarty in https://github.com/element-hq/element-x-android/pull/3441
Changes in Element X v0.5.3 (2024-09-10)
========================================

View file

@ -12,5 +12,5 @@ object OnBoardingConfig {
const val CAN_LOGIN_WITH_QR_CODE = true
/** Whether the user can create an account using the app. */
const val CAN_CREATE_ACCOUNT = false
const val CAN_CREATE_ACCOUNT = true
}

View file

@ -59,6 +59,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.impl)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.networkmonitor.test)

View file

@ -9,4 +9,6 @@ package io.element.android.appnav.loggedin
sealed interface LoggedInEvents {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
data object CheckSlidingSyncProxyAvailability : LoggedInEvents
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvents
}

View file

@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.RegistrationFailure
import io.element.android.services.analytics.api.AnalyticsService
@ -48,6 +50,7 @@ class LoggedInPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
) : Presenter<LoggedInState> {
@Composable
override fun present(): LoggedInState {
@ -78,6 +81,7 @@ class LoggedInPresenter @Inject constructor(
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
}
}
var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
combine(
sessionVerificationService.sessionVerifiedStatus,
@ -97,6 +101,18 @@ class LoggedInPresenter @Inject constructor(
}
}
}
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
// Force the user to log out if they were using the proxy sliding sync and it's no longer available, but native sliding sync is.
forceNativeSlidingSyncMigration = !matrixClient.isUsingNativeSlidingSync() &&
matrixClient.isNativeSlidingSyncSupported() &&
!matrixClient.isSlidingSyncProxySupported()
}
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
// Enable native sliding sync if it wasn't already the case
enableNativeSlidingSyncUseCase()
// Then force the logout
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
}
}
}
@ -104,6 +120,7 @@ class LoggedInPresenter @Inject constructor(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState.value,
ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
eventSink = ::handleEvent
)
}

View file

@ -13,5 +13,6 @@ data class LoggedInState(
val showSyncSpinner: Boolean,
val pusherRegistrationState: AsyncData<Unit>,
val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean,
val eventSink: (LoggedInEvents) -> Unit,
)

View file

@ -16,15 +16,18 @@ open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
aLoggedInState(),
aLoggedInState(showSyncSpinner = true),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
aLoggedInState(forceNativeSlidingSyncMigration = true),
)
}
fun aLoggedInState(
showSyncSpinner: Boolean = false,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
eventSink = {},
)

View file

@ -15,10 +15,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.Lifecycle
import io.element.android.appnav.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogWithDoNotShowAgain
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.ui.strings.CommonStrings
@ -28,6 +32,11 @@ fun LoggedInView(
navigateToNotificationTroubleshoot: () -> Unit,
modifier: Modifier = Modifier
) {
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
state.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
}
}
Box(
modifier = modifier
.fillMaxSize()
@ -61,6 +70,13 @@ fun LoggedInView(
}
}
}
// Set the force migration dialog here so it's always displayed over every screen
if (state.forceNativeSlidingSyncMigration) {
ForceNativeSlidingSyncMigrationDialog(onSubmit = {
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
})
}
}
private fun Throwable.getReason(): String? {
@ -80,6 +96,19 @@ private fun Throwable.getReason(): String? {
}
}
@Composable
private fun ForceNativeSlidingSyncMigrationDialog(
onSubmit: () -> Unit,
) {
ErrorDialog(
title = null,
content = stringResource(R.string.banner_migrate_to_native_sliding_sync_force_logout_title),
submitText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action),
onSubmit = onSubmit,
canDismiss = false,
)
}
@PreviewsDayNight
@Composable
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásit se a upgradovat"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Abmelden und aktualisieren"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Dein Homeserver unterstützt das alte Protokoll nicht mehr. Bitte logge dich aus und melde dich wieder an, um die App weiter zu nutzen."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Αποσύνδεση &amp;amp; Αναβάθμιση"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Logi välja ja uuenda"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Sinu koduserver enam ei toeta vana protokolli. Jätkamaks rakenduse kasutamist palun logi välja ning seejärel tagasi."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Déconnecter et mettre à niveau"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Votre serveur daccueil ne prend plus en charge lancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser lapplication."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Kijelentkezés és frissítés"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"A Matrix-kiszolgáló már nem támogatja a régi protokollt. Az alkalmazás további használatához jelentkezzen ki és be."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Выйти и обновить"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш homeserver больше не поддерживает старый протокол. Пожалуйста, выйдите из системы и войдите снова, чтобы продолжить использование приложения."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásiť sa a aktualizovať"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server už nepodporuje starý protokol. Ak chcete pokračovať v používaní aplikácie, odhláste sa a znova sa prihláste."</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Log Out &amp; Upgrade"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."</string>
</resources>

View file

@ -29,6 +29,8 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.pushproviders.api.Distributor
@ -42,6 +44,10 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -91,7 +97,8 @@ class LoggedInPresenterTest {
pushService = FakePushService(),
sessionVerificationService = verificationService,
analyticsService = analyticsService,
encryptionService = encryptionService
encryptionService = encryptionService,
enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -487,26 +494,103 @@ class LoggedInPresenterTest {
)
}
@Test
fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest {
// The migration will be forced if:
// - The user is not using the native sliding sync
// - The sliding sync proxy is no longer supported
// - The native sliding sync is supported
val matrixClient = FakeMatrixClient(
isUsingNativeSlidingSyncLambda = { false },
isSlidingSyncProxySupportedLambda = { false },
isNativeSlidingSyncSupportedLambda = { true },
)
val presenter = createLoggedInPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
}
}
@Test
fun `present - CheckSlidingSyncProxyAvailability will not force the migration if native sliding sync is not supported too`() = runTest {
val matrixClient = FakeMatrixClient(
isUsingNativeSlidingSyncLambda = { false },
isSlidingSyncProxySupportedLambda = { false },
isNativeSlidingSyncSupportedLambda = { false },
)
val presenter = createLoggedInPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
expectNoEvents()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - LogoutAndMigrateToNativeSlidingSync enables native sliding sync and logs out the user`() = runTest {
val logoutLambda = lambdaRecorder<Boolean, Boolean, String?> { userInitiated, ignoreSdkError ->
assertThat(userInitiated).isTrue()
assertThat(ignoreSdkError).isTrue()
null
}
val matrixClient = FakeMatrixClient().apply {
this.logoutLambda = logoutLambda
}
val appPreferencesStore = InMemoryAppPreferencesStore()
val enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(appPreferencesStore, this)
val presenter = createLoggedInPresenter(matrixClient = matrixClient, enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
advanceUntilIdle()
assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
assertThat(logoutLambda.assertions().isCalledOnce())
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}
private fun createLoggedInPresenter(
private fun TestScope.createLoggedInPresenter(
roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline,
analyticsService: AnalyticsService = FakeAnalyticsService(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
pushService: PushService = FakePushService(),
enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this),
matrixClient: MatrixClient = FakeMatrixClient(roomListService = roomListService),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
matrixClient = matrixClient,
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = pushService,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
encryptionService = encryptionService
encryptionService = encryptionService,
enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase,
)
}
}

View file

@ -0,0 +1,2 @@
Element X is the new generation of Element for professional and personal use on mobile. Its the fastest Matrix client with a seamless & intuitive user interface.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -3,4 +3,5 @@
<string name="screen_analytics_settings_help_us_improve">"Deel anonieme gebruiksgegevens om ons te helpen problemen te identificeren."</string>
<string name="screen_analytics_settings_read_terms">"Je kunt al onze voorwaarden %1$s lezen."</string>
<string name="screen_analytics_settings_read_terms_content_link">"hier"</string>
<string name="screen_analytics_settings_share_data">"Gebruiksgegevens delen"</string>
</resources>

View file

@ -111,7 +111,7 @@ internal fun CallScreenView(
is AsyncData.Failure ->
ErrorDialog(
content = state.urlState.error.message.orEmpty(),
onDismiss = { state.eventSink(CallScreenEvents.Hangup) },
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
is AsyncData.Success -> Unit
}

View file

@ -112,8 +112,8 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<AsyncAction<RoomId>>) = launch {
suspend {
client.getRoom(roomId)?.use {
it.leave().getOrThrow()
client.getInvitedRoom(roomId)?.use {
it.declineInvite().getOrThrow()
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
}
roomId

View file

@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeInvitedRoom
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
@ -83,12 +83,7 @@ class AcceptDeclineInvitePresenterTest {
Result.failure<Unit>(RuntimeException("Failed to leave room"))
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom(
leaveRoomLambda = declineInviteFailure
)
)
getInvitedRoomResults[A_ROOM_ID] = FakeInvitedRoom(declineInviteResult = declineInviteFailure)
}
val presenter = createAcceptDeclineInvitePresenter(client = client)
presenter.test {
@ -133,12 +128,7 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit)
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom(
leaveRoomLambda = declineInviteSuccess
)
)
getInvitedRoomResults[A_ROOM_ID] = FakeInvitedRoom(declineInviteResult = declineInviteSuccess)
}
val presenter = createAcceptDeclineInvitePresenter(
client = client,

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Toetreden tot de kamer"</string>
<string name="screen_join_room_knock_action">"Klop om deel te nemen"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s ondersteunt nog geen spaces. Je kunt spaces benaderen via de webbrowser."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces worden nog niet ondersteund"</string>
<string name="screen_join_room_subtitle_knock">"Klik op de knop hieronder en een kamerbeheerder wordt op de hoogte gebracht. Na goedkeuring kun je deelnemen aan het gesprek."</string>
<string name="screen_join_room_subtitle_no_preview">"Je moet lid zijn van deze kamer om de berichtgeschiedenis te bekijken."</string>
<string name="screen_join_room_title_knock">"Wil je tot deze kamer toetreden?"</string>
<string name="screen_join_room_title_no_preview">"Voorbeeld is niet beschikbaar"</string>
</resources>

View file

@ -105,7 +105,7 @@ private fun LeaveRoomErrorDialog(
is LeaveRoomState.Error.Hidden -> {}
is LeaveRoomState.Error.Shown -> ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(LeaveRoomEvent.HideError) }
onSubmit = { state.eventSink(LeaveRoomEvent.HideError) }
)
}
}

View file

@ -116,7 +116,7 @@ private fun SetupPinContent(
ErrorDialog(
title = state.setupPinFailure.title(),
content = state.setupPinFailure.content(),
onDismiss = {
onSubmit = {
state.eventSink(SetupPinEvents.ClearFailure)
}
)

View file

@ -104,7 +104,7 @@ fun PinUnlockView(
if (state.showBiometricUnlockError) {
ErrorDialog(
content = state.biometricUnlockErrorMessage ?: "",
onDismiss = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
onSubmit = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
)
}
}
@ -206,7 +206,7 @@ private fun SignOutPrompt(
ErrorDialog(
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
onDismiss = onSignOut,
onSubmit = onSignOut,
)
}
}

View file

@ -28,8 +28,8 @@ Wähle etwas Einprägsames. Bei falscher Eingabe wirst du aus der App ausgeloggt
<item quantity="other">"Du hast %1$d Versuche zum Entsperren"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Falsche PIN. Du hast %1$d weitere Chance"</item>
<item quantity="other">"Falsche PIN. Du hast %1$d weitere Chancen"</item>
<item quantity="one">"Falsche PIN. Du hast %1$d weiteren Versuch"</item>
<item quantity="other">"Falsche PIN. Du hast %1$d weitere Versuche"</item>
</plurals>
<string name="screen_app_lock_use_biometric_android">"Biometrie verwenden"</string>
<string name="screen_app_lock_use_pin_android">"PIN verwenden"</string>

View file

@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.oidc.api)
implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.androidx.webkit)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
api(projects.features.login.api)

View file

@ -30,6 +30,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.libraries.architecture.BackstackView
@ -109,6 +110,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
data object LoginPassword : NavTarget
@Parcelize
data class CreateAccount(val url: String) : NavTarget
@Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
}
@ -140,6 +144,10 @@ class LoginFlowNode @AssistedInject constructor(
}
}
override fun onCreateAccountContinue(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun onLoginPasswordNeeded() {
backstack.push(NavTarget.LoginPassword)
}
@ -180,6 +188,12 @@ class LoginFlowNode @AssistedInject constructor(
is NavTarget.OidcView -> {
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
}
is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs(
url = navTarget.url,
)
createNode<CreateAccountNode>(buildContext, listOf(inputs))
}
}
}

View file

@ -36,7 +36,7 @@ fun ChangeServerView(
ErrorDialog(
modifier = modifier,
content = error.message(),
onDismiss = {
onSubmit = {
eventSink.invoke(ChangeServerEvents.ClearError)
}
)

View file

@ -0,0 +1,26 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.resolver.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Example:
* <pre>
* {
* "registration_helper_url": "https://element.io"
* }
* </pre>
* .
*/
@Serializable
data class ElementWellKnown(
@SerialName("registration_helper_url")
val registrationHelperUrl: String? = null,
)

View file

@ -12,4 +12,7 @@ import retrofit2.http.GET
internal interface WellknownAPI {
@GET(".well-known/matrix/client")
suspend fun getWellKnown(): WellKnown
@GET(".well-known/element/element.json")
suspend fun getElementWellKnown(): ElementWellKnown
}

View file

@ -43,6 +43,7 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
interface Callback : Plugin {
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onCreateAccountContinue(url: String)
fun onChangeAccountProvider()
}
@ -54,6 +55,10 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onCreateAccountContinue(url: String) {
plugins<Callback>().forEach { it.onCreateAccountContinue(url) }
}
private fun onChangeAccountProvider() {
plugins<Callback>().forEach { it.onChangeAccountProvider() }
}
@ -67,6 +72,7 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
modifier = modifier,
onOidcDetails = ::onOidcDetails,
onNeedLoginPassword = ::onLoginPasswordNeeded,
onCreateAccountContinue = ::onCreateAccountContinue,
onChange = ::onChangeAccountProvider,
onLearnMoreClick = { openLearnMorePage(context) },
)

View file

@ -21,6 +21,8 @@ import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -36,6 +38,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
private val authenticationService: MatrixAuthenticationService,
private val oidcActionFlow: OidcActionFlow,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
@ -90,13 +93,24 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
} else if (params.isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginFlow.AccountCreationFlow(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginFlow.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from)
}.runCatchingUpdatingState(
state = loginFlowAction,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOidcAction(

View file

@ -24,4 +24,5 @@ data class ConfirmAccountProviderState(
sealed interface LoginFlow {
data object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
data class AccountCreationFlow(val url: String) : LoginFlow
}

View file

@ -8,20 +8,33 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<ConfirmAccountProviderState> {
override val values: Sequence<ConfirmAccountProviderState>
get() = sequenceOf(
aConfirmAccountProviderState(),
// Add other state here
aConfirmAccountProviderState(
isAccountCreation = true,
),
aConfirmAccountProviderState(
isAccountCreation = true,
loginFlow = AsyncData.Failure(AccountCreationNotSupported())
),
)
}
fun aConfirmAccountProviderState() = ConfirmAccountProviderState(
accountProvider = anAccountProvider(),
isAccountCreation = false,
loginFlow = AsyncData.Uninitialized,
eventSink = {}
private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(),
isAccountCreation: Boolean = false,
loginFlow: AsyncData<LoginFlow> = AsyncData.Uninitialized,
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = isAccountCreation,
loginFlow = loginFlow,
eventSink = eventSink
)

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@ -42,6 +43,7 @@ fun ConfirmAccountProviderView(
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -103,18 +105,29 @@ fun ConfirmAccountProviderView(
is ChangeServerError.Error -> {
ErrorDialog(
content = error.message(),
onDismiss = {
onSubmit = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(onLearnMoreClick = {
onLearnMoreClick()
eventSink(ConfirmAccountProviderEvents.ClearError)
}, onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
})
SlidingSyncNotSupportedDialog(
onLearnMoreClick = {
onLearnMoreClick()
eventSink(ConfirmAccountProviderEvents.ClearError)
},
onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
}
)
}
is AccountCreationNotSupported -> {
ErrorDialog(
content = stringResource(CommonStrings.error_account_creation_not_possible),
onSubmit = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
}
}
@ -123,6 +136,7 @@ fun ConfirmAccountProviderView(
when (val loginFlowState = state.loginFlow.data) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onNeedLoginPassword()
is LoginFlow.AccountCreationFlow -> onCreateAccountContinue(loginFlowState.url)
}
}
AsyncData.Uninitialized -> Unit
@ -139,6 +153,7 @@ internal fun ConfirmAccountProviderViewPreview(
state = state,
onOidcDetails = {},
onNeedLoginPassword = {},
onCreateAccountContinue = {},
onLearnMoreClick = {},
onChange = {},
)

View file

@ -0,0 +1,10 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
class AccountCreationNotSupported : Exception()

View file

@ -0,0 +1,13 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
sealed interface CreateAccountEvents {
data class SetPageProgress(val progress: Int) : CreateAccountEvents
data class OnMessageReceived(val message: String) : CreateAccountEvents
}

View file

@ -0,0 +1,56 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class CreateAccountNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: CreateAccountPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val url: String,
) : NodeInputs
private val presenter = presenterFactory.create(inputs<Inputs>().url)
private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present()
CreateAccountView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onOpenExternalUrl = {
onOpenExternalUrl(activity, isDark, it)
},
)
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class CreateAccountPresenter @AssistedInject constructor(
@Assisted private val url: String,
private val authenticationService: MatrixAuthenticationService,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val messageParser: MessageParser,
private val buildMeta: BuildMeta,
) : Presenter<CreateAccountState> {
@AssistedFactory
interface Factory {
fun create(url: String): CreateAccountPresenter
}
@Composable
override fun present(): CreateAccountState {
val coroutineScope = rememberCoroutineScope()
val pageProgress: MutableState<Int> = remember { mutableIntStateOf(0) }
val createAction: MutableState<AsyncAction<SessionId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
fun handleEvents(event: CreateAccountEvents) {
when (event) {
is CreateAccountEvents.SetPageProgress -> {
pageProgress.value = event.progress
}
is CreateAccountEvents.OnMessageReceived -> {
// Ignore unexpected message
if (event.message.contains("isTrusted")) return
coroutineScope.importSession(event.message, createAction)
}
}
}
return CreateAccountState(
url = url,
pageProgress = pageProgress.value,
isDebugBuild = buildMeta.isDebuggable,
createAction = createAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.importSession(message: String, loggedInState: MutableState<AsyncAction<SessionId>>) = launch {
loggedInState.value = AsyncAction.Loading
runCatching {
messageParser.parse(message)
}.flatMap { externalSession ->
authenticationService.importCreatedSession(externalSession)
}.onSuccess { sessionId ->
// We will not navigate to the WaitList screen, so the login user story is done
defaultLoginUserStory.setLoginFlowIsDone(true)
loggedInState.value = AsyncAction.Success(sessionId)
}.onFailure { failure ->
loggedInState.value = AsyncAction.Failure(failure)
}
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.SessionId
data class CreateAccountState(
val url: String,
val pageProgress: Int,
val createAction: AsyncAction<SessionId>,
val isDebugBuild: Boolean,
val eventSink: (CreateAccountEvents) -> Unit
)

View file

@ -0,0 +1,33 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.SessionId
open class CreateAccountStateProvider : PreviewParameterProvider<CreateAccountState> {
override val values: Sequence<CreateAccountState>
get() = sequenceOf(
aCreateAccountState(),
aCreateAccountState(pageProgress = 33),
aCreateAccountState(createAction = AsyncAction.Loading),
aCreateAccountState(createAction = AsyncAction.Failure(Throwable("Failed to create account"))),
)
}
private fun aCreateAccountState(
pageProgress: Int = 100,
createAction: AsyncAction<SessionId> = AsyncAction.Uninitialized,
) = CreateAccountState(
url = "https://example.com",
isDebugBuild = true,
pageProgress = pageProgress,
createAction = createAction,
eventSink = {}
)

View file

@ -0,0 +1,181 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.JsResult
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateAccountView(
state: CreateAccountState,
onBackClick: () -> Unit,
onOpenExternalUrl: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
stringResource(R.string.screen_create_account_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = onBackClick)
},
)
}
) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.consumeWindowInsets(contentPadding)
.fillMaxSize()
) {
CreateAccountWebView(
modifier = Modifier
.fillMaxSize(),
state = state,
onWebViewCreate = { webView ->
WebViewMessageInterceptor(
webView,
state.isDebugBuild,
onOpenExternalUrl = onOpenExternalUrl,
onMessage = {
state.eventSink(CreateAccountEvents.OnMessageReceived(it))
},
)
}
)
AnimatedVisibility(
visible = state.pageProgress != 100,
// Disable enter animation
enter = fadeIn(initialAlpha = 1f),
exit = fadeOut(),
) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(2.dp),
progress = { state.pageProgress / 100f },
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
)
}
}
}
AsyncActionView(
async = state.createAction,
onSuccess = {},
onErrorDismiss = onBackClick,
onRetry = null
)
}
@Composable
private fun CreateAccountWebView(
state: CreateAccountState,
onWebViewCreate: (WebView) -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Text("WebView - can't be previewed")
}
} else {
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
onWebViewCreate(this)
setup(state)
}
},
update = { webView ->
if (webView.url != state.url) {
webView.loadUrl(state.url)
}
},
onRelease = { webView ->
webView.destroy()
}
)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.setup(state: CreateAccountState) {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
with(settings) {
javaScriptEnabled = true
domStorageEnabled = true
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
state.eventSink(CreateAccountEvents.SetPageProgress(newProgress))
}
override fun onJsBeforeUnload(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {
Timber.w("onJsBeforeUnload, cancelling the dialog, we will open external links in a Custom Chrome Tab")
result?.confirm()
return true
}
}
}
@PreviewsDayNight
@Composable
internal fun CreateAccountViewPreview(@PreviewParameter(CreateAccountStateProvider::class) state: CreateAccountState) = ElementPreview {
CreateAccountView(
state = state,
onBackClick = {},
onOpenExternalUrl = {},
)
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import kotlinx.serialization.json.Json
import javax.inject.Inject
interface MessageParser {
/**
* Parse the message and return the ExternalSession object, or
* throw an exception if the message is invalid.
*/
fun parse(message: String): ExternalSession
}
@ContributesBinding(AppScope::class)
class DefaultMessageParser @Inject constructor(
private val accountProviderDataSource: AccountProviderDataSource,
) : MessageParser {
override fun parse(message: String): ExternalSession {
val parser = Json { ignoreUnknownKeys = true }
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
val userId = response.userId ?: error("No user ID in response")
val homeServer = response.homeServer ?: accountProviderDataSource.flow().value.url
val accessToken = response.accessToken ?: error("No access token in response")
val deviceId = response.deviceId ?: error("No device ID in response")
return ExternalSession(
userId = userId,
homeserverUrl = homeServer,
accessToken = accessToken,
deviceId = deviceId,
refreshToken = null,
slidingSyncProxy = null
)
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* For ref:
* https://github.com/element-hq/matrix-react-sdk/pull/42/files#diff-2bbba5a742004fd4e924a639ded444279f66f7ad890cb669fbc91ac6b8638c64R56
*/
@Serializable
data class MobileRegistrationResponse(
@SerialName("user_id")
val userId: String? = null,
@SerialName("home_server")
val homeServer: String? = null,
@SerialName("access_token")
val accessToken: String? = null,
@SerialName("device_id")
val deviceId: String? = null,
)

View file

@ -0,0 +1,90 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import android.graphics.Bitmap
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
class WebViewMessageInterceptor(
webView: WebView,
private val debugLog: Boolean,
private val onOpenExternalUrl: (String) -> Unit,
private val onMessage: (String) -> Unit,
) {
companion object {
// We call both the WebMessageListener and the JavascriptInterface objects in JS with this
// 'listenerName' so they can both receive the data from the WebView when
// `${LISTENER_NAME}.postMessage(...)` is called
const val LISTENER_NAME = "elementX"
}
init {
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// We inject this JS code when the page starts loading to attach a message listener to the window.
view?.evaluateJavascript(
"""
window.addEventListener(
"mobileregistrationresponse",
(event) => {
let json = JSON.stringify(event.detail)
${"console.log('message sent: ' + json);".takeIf { debugLog }}
$LISTENER_NAME.postMessage(json);
},
false,
);
""".trimIndent(),
null
)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
request ?: return super.shouldOverrideUrlLoading(view, request)
// Load the URL in a Chrome Custom Tab, and return true to cancel the load
onOpenExternalUrl(request.url.toString())
return true
}
}
// Use WebMessageListener if supported, otherwise use JavascriptInterface
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
// Create a WebMessageListener, which will receive messages from the WebView and reply to them
val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
onMessageReceived(message.data)
}
WebViewCompat.addWebMessageListener(
webView,
LISTENER_NAME,
setOf("*"),
webMessageListener
)
} else {
webView.addJavascriptInterface(
object {
@JavascriptInterface
fun postMessage(json: String?) {
onMessageReceived(json)
}
},
LISTENER_NAME,
)
}
}
private fun onMessageReceived(json: String?) {
// Here is where we would handle the messages from the WebView, passing them to the listener
json?.let { onMessage(it) }
}
}

View file

@ -283,7 +283,7 @@ private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_error),
content = stringResource(loginError(error)),
onDismiss = onDismiss
onSubmit = onDismiss
)
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.web
import android.net.Uri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.resolver.network.WellknownAPI
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.network.RetrofitFactory
import timber.log.Timber
import java.net.HttpURLConnection
import javax.inject.Inject
interface WebClientUrlForAuthenticationRetriever {
suspend fun retrieve(homeServerUrl: String): String
}
@ContributesBinding(AppScope::class)
class DefaultWebClientUrlForAuthenticationRetriever @Inject constructor(
private val retrofitFactory: RetrofitFactory,
) : WebClientUrlForAuthenticationRetriever {
override suspend fun retrieve(homeServerUrl: String): String {
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
Timber.w("Temporary account creation flow is only supported on matrix.org")
throw AccountCreationNotSupported()
}
val wellknownApi = retrofitFactory.create(homeServerUrl)
.create(WellknownAPI::class.java)
val result = try {
wellknownApi.getElementWellKnown()
} catch (e: retrofit2.HttpException) {
throw when {
e.code() == HttpURLConnection.HTTP_NOT_FOUND -> AccountCreationNotSupported()
else -> e
}
}
val registrationHelperUrl = result.registrationHelperUrl
return if (registrationHelperUrl != null) {
Uri.parse(registrationHelperUrl)
.buildUpon()
.appendQueryParameter("hs_url", homeServerUrl)
.build()
.toString()
} else {
throw AccountCreationNotSupported()
}
}
}

View file

@ -78,10 +78,4 @@
<string name="screen_server_confirmation_message_register">"Тут будуць захоўвацца вашыя размовы - сапраўды гэтак жа, як вы выкарыстоўваеце паштовага правайдара для захоўвання сваіх лістоў."</string>
<string name="screen_server_confirmation_title_login">"Вы збіраецеся ўвайсці ў %1$s"</string>
<string name="screen_server_confirmation_title_register">"Вы збіраецеся стварыць уліковы запіс на %1$s"</string>
<string name="screen_waitlist_message">"Зараз існуе высокі попыт на %1$s на %2$s. Калі ласка, вярніцеся ў праграму праз некалькі дзён і паспрабуйце зноў.
Дзякуй за цярпенне!"</string>
<string name="screen_waitlist_message_success">"Вітаем у %1$s!"</string>
<string name="screen_waitlist_title">"Амаль гатова."</string>
<string name="screen_waitlist_title_success">"Вы зарэгістраваны."</string>
</resources>

View file

@ -24,5 +24,4 @@
<string name="screen_server_confirmation_message_register">"Това е мястото, където ще живеят вашите разговори — точно както бихте използвали имейл доставчик, за да съхранявате вашите имейли."</string>
<string name="screen_server_confirmation_title_login">"На път сте да влезете в %1$s"</string>
<string name="screen_server_confirmation_title_register">"На път сте да създадете акаунт в %1$s"</string>
<string name="screen_waitlist_message_success">"Добре дошли в %1$s!"</string>
</resources>

View file

@ -78,10 +78,4 @@ Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízen
<string name="screen_server_confirmation_message_register">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
<string name="screen_server_confirmation_title_login">"Chystáte se přihlásit do služby %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte se vytvořit účet na %1$s"</string>
<string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu.
Díky za trpělivost!"</string>
<string name="screen_waitlist_message_success">"Vítá vás %1$s!"</string>
<string name="screen_waitlist_title">"Jste v pořadníku!"</string>
<string name="screen_waitlist_title_success">"Jdete do toho!"</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Du kannst nur eine Verbindung zu einem vorhandenen Server herstellen, der Sliding Sync unterstützt. Dein Homeserver-Administrator muss das konfigurieren. %1$s"</string>
<string name="screen_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string>
<string name="screen_change_server_title">"Wähle deinen Server aus"</string>
<string name="screen_create_account_title">"Konto erstellen"</string>
<string name="screen_login_error_deactivated_account">"Dieses Konto wurde deaktiviert."</string>
<string name="screen_login_error_invalid_credentials">"Falscher Benutzername und/oder Passwort"</string>
<string name="screen_login_error_invalid_user_id">"Dies ist keine gültige Benutzerkennung. Erwartetes Format: \'@user:homeserver.org\'"</string>
@ -78,10 +79,4 @@ Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Ger
<string name="screen_server_confirmation_message_register">"Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würden."</string>
<string name="screen_server_confirmation_title_login">"Du bist dabei, dich bei %1$s anzumelden"</string>
<string name="screen_server_confirmation_title_register">"Du bist dabei, ein Konto auf %1$s zu erstellen"</string>
<string name="screen_waitlist_message">"Derzeit besteht eine hohe Nachfrage nach %1$s auf %2$s. Kehre in ein paar Tagen zur App zurück und versuche es erneut.
Danke für deine Geduld!"</string>
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
<string name="screen_waitlist_title">"Du bist fast am Ziel."</string>
<string name="screen_waitlist_title_success">"Du bist dabei."</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Μπορείτε να συνδεθείς μόνο σε υπάρχοντα διακομιστή που υποστηρίζει Sliding sync. Ο διαχειριστής του οικιακού διακομιστή σου θα πρέπει να το ρυθμίσει. %1$s"</string>
<string name="screen_change_server_subtitle">"Ποια είναι η διεύθυνση του διακομιστή σου;"</string>
<string name="screen_change_server_title">"Επέλεξε το διακομιστή σου"</string>
<string name="screen_create_account_title">"Δημιουργία λογαριασμού"</string>
<string name="screen_login_error_deactivated_account">"Αυτός ο λογαριασμός έχει απενεργοποιηθεί."</string>
<string name="screen_login_error_invalid_credentials">"Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης"</string>
<string name="screen_login_error_invalid_user_id">"Αυτό δεν είναι έγκυρο αναγνωριστικό χρήστη. Αναμενόμενη μορφή: \'@χρήστης:homeserver.org\'"</string>
@ -78,10 +79,4 @@
<string name="screen_server_confirmation_message_register">"Εδώ θα ζουν οι συνομιλίες σου - όπως θα χρησιμοποιούσες έναν πάροχο email για να διατηρήσεις τα email σου."</string>
<string name="screen_server_confirmation_title_login">"Πρόκειται να συνδεθείς στο %1$s"</string>
<string name="screen_server_confirmation_title_register">"Πρόκειται να δημιουργήσεις έναν λογαριασμό στο %1$s"</string>
<string name="screen_waitlist_message">"Υπάρχει μεγάλη ζήτηση για το %1$s στον %2$s αυτή τη στιγμή. Επέστρεψε στην εφαρμογή σε λίγες μέρες και δοκίμασε ξανά.
Ευχαριστώ για την υπομονή σου!"</string>
<string name="screen_waitlist_message_success">"Καλώς ήρθες στο %1$s!"</string>
<string name="screen_waitlist_title">"Σχεδόν τα κατάφερες."</string>
<string name="screen_waitlist_title_success">"Είσαι μέσα."</string>
</resources>

View file

@ -37,10 +37,4 @@
<string name="screen_server_confirmation_message_register">"Aquí es donde se alojarán tus conversaciones — justo como utilizarías un proveedor de correo electrónico para guardar tus correos electrónicos."</string>
<string name="screen_server_confirmation_title_login">"Estás a punto de iniciar sesión en %1$s"</string>
<string name="screen_server_confirmation_title_register">"Estás a punto de crear una cuenta en %1$s"</string>
<string name="screen_waitlist_message">"Hay una gran demanda para %1$s en %2$s en este momento. Vuelve a la aplicación en unos días e inténtalo de nuevo.
¡Gracias por tu paciencia!"</string>
<string name="screen_waitlist_message_success">"¡Bienvenido a %1$s!"</string>
<string name="screen_waitlist_title">"Ya casi has terminado."</string>
<string name="screen_waitlist_title_success">"Estás dentro."</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Sa saad luua ühendust vaid olemasoleva serveriga, mis toetab Sliding sync režiimi. Sinu koduserveri haldur peaks selle seadistama. %1$s"</string>
<string name="screen_change_server_subtitle">"Mis on sinu koduserveri aadress?"</string>
<string name="screen_change_server_title">"Vali oma server"</string>
<string name="screen_create_account_title">"Loo kasutajakonto"</string>
<string name="screen_login_error_deactivated_account">"Konto on kasutusest eemaldatud."</string>
<string name="screen_login_error_invalid_credentials">"Vigane kasutajanimi ja/või salasõna"</string>
<string name="screen_login_error_invalid_user_id">"See ei ole korrektne kasutajanimi. Õige vorming on: „@kasutaja:koduserver.ee“"</string>
@ -78,10 +79,4 @@ Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega."</strin
<string name="screen_server_confirmation_message_register">"See on koht, kus sinu vestlused elavad just nagu kasutaksid oma e-kirjade säilitamiseks e-postitenuse pakkujat."</string>
<string name="screen_server_confirmation_title_login">"Sa oled sisselogimas koduserverisse %1$s"</string>
<string name="screen_server_confirmation_title_register">"Sa oled loomas kasutajakontot koduserveris %1$s"</string>
<string name="screen_waitlist_message">"%1$s kasutamiseks %2$s koduserveris on hetkel palju huvilisi. Proovi seda samast rakendusest mõne päeva pärast.
Täname kannatlikkuse eest!"</string>
<string name="screen_waitlist_message_success">"Tere tulemast rakendusse %1$s!"</string>
<string name="screen_waitlist_title">"Peaaegu olemas."</string>
<string name="screen_waitlist_title_success">"Oled nüüd jututoas."</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Vous ne pouvez vous connecter quà un serveur existant qui prend en charge le sliding sync. Ladministrateur de votre serveur daccueil devra le configurer. %1$s"</string>
<string name="screen_change_server_subtitle">"Quelle est ladresse de votre serveur ?"</string>
<string name="screen_change_server_title">"Choisissez votre serveur"</string>
<string name="screen_create_account_title">"Créer un compte"</string>
<string name="screen_login_error_deactivated_account">"Ce compte a été désactivé."</string>
<string name="screen_login_error_invalid_credentials">"Nom dutilisateur et/ou mot de passe incorrects"</string>
<string name="screen_login_error_invalid_user_id">"Il ne sagit pas dun identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"</string>
@ -76,10 +77,4 @@
<string name="screen_server_confirmation_message_register">"Cest ici que vos conversations seront enregistrées, comme vous le feriez avec un fournisseur de messagerie pour conserver vos e-mails."</string>
<string name="screen_server_confirmation_title_login">"Vous êtes sur le point de vous connecter à %1$s"</string>
<string name="screen_server_confirmation_title_register">"Vous êtes sur le point de créer un compte sur %1$s"</string>
<string name="screen_waitlist_message">"Il y a une forte demande pour %1$s sur %2$s à lheure actuelle. Revenez sur lapplication dans quelques jours et réessayez.
Merci pour votre patience !"</string>
<string name="screen_waitlist_message_success">"Bienvenue dans %1$s !"</string>
<string name="screen_waitlist_title">"Vous y êtes presque."</string>
<string name="screen_waitlist_title_success">"Vous y êtes."</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Csak olyan meglévő kiszolgálóhoz csatlakozhat, amely támogatja a Sliding sync protokollt. Ezt a Matrix-kiszolgáló adminisztrátorának kell beállítania. %1$s"</string>
<string name="screen_change_server_subtitle">"Mi a kiszolgálója címe?"</string>
<string name="screen_change_server_title">"Válassza ki a kiszolgálóját"</string>
<string name="screen_create_account_title">"Fiók létrehozása"</string>
<string name="screen_login_error_deactivated_account">"Ez a fiók deaktiválva lett."</string>
<string name="screen_login_error_invalid_credentials">"Helytelen felhasználónév vagy jelszó"</string>
<string name="screen_login_error_invalid_user_id">"Ez nem érvényes felhasználóazonosító. A várt formátum: „@user:homeserver.org”"</string>
@ -78,10 +79,4 @@ Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik e
<string name="screen_server_confirmation_message_register">"Itt lesznek a beszélgetései ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez."</string>
<string name="screen_server_confirmation_title_login">"Hamarosan bejelentkezik ebbe: %1$s"</string>
<string name="screen_server_confirmation_title_register">"Hamarosan létrehoz egy fiókot ezen: %1$s"</string>
<string name="screen_waitlist_message">"Jelenleg nagy a kereslet a(z) %2$s oldalon futó %1$s iránt. Térjen vissza néhány nap múlva az alkalmazáshoz, és próbálja újra.
Köszönjük a türelmét!"</string>
<string name="screen_waitlist_message_success">"Üdvözli az %1$s!"</string>
<string name="screen_waitlist_title">"Már majdnem kész van."</string>
<string name="screen_waitlist_title_success">"Bent van."</string>
</resources>

View file

@ -78,10 +78,4 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain."</string>
<string name="screen_server_confirmation_message_register">"Di sinilah percakapan Anda akan berlangsung — sama seperti Anda menggunakan penyedia surel untuk menyimpan surel Anda."</string>
<string name="screen_server_confirmation_title_login">"Anda akan masuk ke %1$s"</string>
<string name="screen_server_confirmation_title_register">"Anda akan membuat akun di %1$s"</string>
<string name="screen_waitlist_message">"Ada permintaan tinggi untuk %1$s di %2$s saat ini. Kembalilah ke aplikasi dalam beberapa hari dan coba lagi.
Terima kasih atas kesabaran Anda!"</string>
<string name="screen_waitlist_message_success">"Selamat datang di %1$s!"</string>
<string name="screen_waitlist_title">"Anda hampir selesai."</string>
<string name="screen_waitlist_title_success">"Anda sudah masuk."</string>
</resources>

View file

@ -78,10 +78,4 @@ Prova ad accedere manualmente o scansiona il codice QR con un altro dispositivo.
<string name="screen_server_confirmation_message_register">"Qui è dove vivranno le tue conversazioni — proprio come useresti un fornitore di posta elettronica per conservare le tue email."</string>
<string name="screen_server_confirmation_title_login">"Stai per accedere a %1$s"</string>
<string name="screen_server_confirmation_title_register">"Stai per creare un account su %1$s"</string>
<string name="screen_waitlist_message">"Al momento c\'è una grande richiesta per %1$s su %2$s. Torna a visitare l\'app tra qualche giorno e riprova.
Grazie per la pazienza!"</string>
<string name="screen_waitlist_message_success">"Benvenuti in %1$s!"</string>
<string name="screen_waitlist_title">"Ci sei quasi."</string>
<string name="screen_waitlist_title_success">"Sei dentro."</string>
</resources>

View file

@ -34,10 +34,4 @@
<string name="screen_server_confirmation_message_register">"აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები."</string>
<string name="screen_server_confirmation_title_login">"თქვენ აპირებთ შესვლას %1$s-ში"</string>
<string name="screen_server_confirmation_title_register">"თქვენ აპირებთ ანგარიშის შექმნას %1$s-ში"</string>
<string name="screen_waitlist_message">"ახლა დიდი მოთხოვნაა %1$s-ზე %2$s-ში. დაბრუნდით რამდენიმე დღეში და სცადეთ ერთხელაც.
მადლობა მოთმენისათვის!"</string>
<string name="screen_waitlist_message_success">"კეთილი იყოს თქვენი მობრძანება %1$s-ში!"</string>
<string name="screen_waitlist_title">"თითქმის მზადაა."</string>
<string name="screen_waitlist_title_success">"თქვენ შეხვედით."</string>
</resources>

View file

@ -14,6 +14,8 @@
<string name="screen_change_account_provider_subtitle">"Gebruik een andere accountprovider, zoals je eigen privéserver of een zakelijke account."</string>
<string name="screen_change_account_provider_title">"Wijzig accountprovider"</string>
<string name="screen_change_server_error_invalid_homeserver">"We konden deze homeserver niet bereiken. Controleer of je de homeserver-URL juist hebt ingevoerd. Als de URL juist is, neem dan contact op met de beheerder van je homeserver voor verdere hulp."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync is niet beschikbaar vanwege een probleem in het well-known bestand:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Deze server ondersteunt op dit moment geen sliding sync."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_form_notice">"Je kunt alleen verbinding maken met een bestaande server die sliding sync ondersteunt. De beheerder van de homeserver moet dit configureren. %1$s"</string>
@ -28,17 +30,45 @@
<string name="screen_login_subtitle">"Matrix is een open netwerk voor veilige, gedecentraliseerde communicatie."</string>
<string name="screen_login_title">"Welkom terug!"</string>
<string name="screen_login_title_with_homeserver">"Inloggen bij %1$s"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Een beveiligde verbinding tot stand brengen"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Er kon geen beveiligde verbinding worden gemaakt met het nieuwe apparaat. Je bestaande apparaten zijn nog steeds veilig en je hoeft je daarover geen zorgen te maken."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Wat nu?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Probeer opnieuw in te loggen met een QR-code voor het geval dit een netwerkprobleem was"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Als je hetzelfde probleem ondervindt, probeer dan een ander wifi-netwerk of gebruik je mobiele data in plaats van wifi."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Als dat niet werkt, log dan handmatig in"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Verbinding niet veilig"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Daar word je gevraagd om de twee cijfers in te voeren die op dit apparaat worden weergegeven."</string>
<string name="screen_qr_code_login_device_code_title">"Voer het onderstaande nummer in op je andere apparaat"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"De aanmelding is geannuleerd op het andere apparaat."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Login verzoek geannuleerd"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"De aanmelding is geweigerd op het andere apparaat."</string>
<string name="screen_qr_code_login_error_declined_title">"Aanmelden geweigerd"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Aanmelden is verlopen. Probeer het opnieuw."</string>
<string name="screen_qr_code_login_error_expired_title">"De aanmelding was niet op tijd voltooid"</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"QR-code wordt niet ondersteund"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Klaar om te scannen"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Open %1$s op een desktopapparaat"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klik op je afbeelding"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Selecteer %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Nieuw apparaat koppelen”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scan de QR-code met dit apparaat"</string>
<string name="screen_qr_code_login_initial_state_title">"Open %1$s op een ander apparaat om de QR-code te krijgen"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Gebruik de QR-code die op het andere apparaat wordt weergegeven."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Probeer het opnieuw"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Verkeerde QR-code"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Ga naar camera-instellingen"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Je moet %1$s toestemming geven om de camera van je apparaat te gebruiken om verder te gaan."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Cameratoegang toestaan om de QR-code te scannen"</string>
<string name="screen_qr_code_login_scanning_state_title">"Scan de QR-code"</string>
<string name="screen_qr_code_login_start_over_button">"Opnieuw beginnen"</string>
<string name="screen_qr_code_login_unknown_error_description">"Er is een onverwachte fout opgetreden. Probeer het opnieuw."</string>
<string name="screen_qr_code_login_verify_code_loading">"Aan het wachten op je andere apparaat"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Je accountprovider kan om de volgende code vragen om de aanmelding te verifiëren."</string>
<string name="screen_qr_code_login_verify_code_title">"Je verificatiecode"</string>
<string name="screen_server_confirmation_change_server">"Accountprovider wijzigen"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Een privéserver voor medewerkers van Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is een open netwerk voor veilige, gedecentraliseerde communicatie."</string>
<string name="screen_server_confirmation_message_register">"Dit is waar je gesprekken zullen worden bewaard — net zoals je een e-mailprovider zou gebruiken om je e-mails te bewaren."</string>
<string name="screen_server_confirmation_title_login">"Je staat op het punt je aan te melden bij %1$s"</string>
<string name="screen_server_confirmation_title_register">"Je staat op het punt een account aan te maken op %1$s"</string>
<string name="screen_waitlist_message">"Er is momenteel veel vraag naar %1$s op %2$s. Kom over een paar dagen terug naar de app en probeer het opnieuw.
Bedankt voor je geduld!"</string>
<string name="screen_waitlist_message_success">"Welkom bij %1$s!"</string>
<string name="screen_waitlist_title">"Je bent er bijna."</string>
<string name="screen_waitlist_title_success">"Je bent binnen."</string>
</resources>

View file

@ -78,10 +78,4 @@ Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu."</st
<string name="screen_server_confirmation_message_register">"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."</string>
<string name="screen_server_confirmation_title_login">"Zamierzasz się zalogować do %1$s"</string>
<string name="screen_server_confirmation_title_register">"Zamierzasz utworzyć konto na %1$s"</string>
<string name="screen_waitlist_message">"Obecnie istnieje duże zapotrzebowanie na %1$s na %2$s. Wróć do aplikacji za kilka dni i spróbuj ponownie.
Dziękujemy za Twoją cierpliwość!"</string>
<string name="screen_waitlist_message_success">"Witamy w %1$s!"</string>
<string name="screen_waitlist_title">"Już prawie gotowe!"</string>
<string name="screen_waitlist_title_success">"Witamy!"</string>
</resources>

View file

@ -34,10 +34,4 @@
<string name="screen_server_confirmation_message_register">"Aqui é onde suas conversas vão ficar — assim como você usa um provedor de e-mails para manter seus e-mails."</string>
<string name="screen_server_confirmation_title_login">"Você está prestes a fazer login em %1$s"</string>
<string name="screen_server_confirmation_title_register">"Você está prestes a criar uma conta em %1$s"</string>
<string name="screen_waitlist_message">"Há uma grande demanda por %1$s sobre %2$s no momento. Volte ao aplicativo em alguns dias e tente novamente.
Obrigado pela sua paciência!"</string>
<string name="screen_waitlist_message_success">"Bem-vindo ao %1$s!"</string>
<string name="screen_waitlist_title">"Você está quase lá."</string>
<string name="screen_waitlist_title_success">"Você está dentro."</string>
</resources>

View file

@ -78,10 +78,4 @@ Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro disposi
<string name="screen_server_confirmation_message_register">"É aqui que as tuas conversas vão ficar — tal como num serviço de e-mail."</string>
<string name="screen_server_confirmation_title_login">"Irás iniciar sessão em %1$s"</string>
<string name="screen_server_confirmation_title_register">"Irás criar uma conta em %1$s"</string>
<string name="screen_waitlist_message">"Há uma grande procura pela %1$s no %2$s, de momento. Volta à aplicação daqui a uns dias e tenta novamente.
Obrigado!"</string>
<string name="screen_waitlist_message_success">"Bem-vindo à %1$s!"</string>
<string name="screen_waitlist_title">"Estás quase lá."</string>
<string name="screen_waitlist_title_success">"Estás dentro."</string>
</resources>

View file

@ -78,10 +78,4 @@
<string name="screen_server_confirmation_message_register">"Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_server_confirmation_title_login">"Sunteți pe cale să vă conectați la %1$s"</string>
<string name="screen_server_confirmation_title_register">"Sunteți pe cale să creați un cont pe %1$s"</string>
<string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou.
Vă mulțumim pentru răbdare!"</string>
<string name="screen_waitlist_message_success">"Bun venit la%1$s!"</string>
<string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string>
<string name="screen_waitlist_title_success">"Sunteți conectat!"</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Вы можете подключиться только к существующему серверу, поддерживающему sliding sync. Администратору домашнего сервера потребуется настроить его. %1$s"</string>
<string name="screen_change_server_subtitle">"Какой адрес у вашего сервера?"</string>
<string name="screen_change_server_title">"Выберите свой сервер"</string>
<string name="screen_create_account_title">"Создать учетную запись"</string>
<string name="screen_login_error_deactivated_account">"Данная учетная запись была деактивирована."</string>
<string name="screen_login_error_invalid_credentials">"Неверное имя пользователя и/или пароль"</string>
<string name="screen_login_error_invalid_user_id">"Это не корректный идентификатор пользователя. Ожидаемый формат: \'@user:homeserver.org\'"</string>
@ -78,10 +79,4 @@
<string name="screen_server_confirmation_message_register">"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."</string>
<string name="screen_server_confirmation_title_login">"Вы собираетесь войти в %1$s"</string>
<string name="screen_server_confirmation_title_register">"Вы собираетесь создать учетную запись на %1$s"</string>
<string name="screen_waitlist_message">"В настоящее время существует высокий спрос на %1$s на %2$s. Вернитесь в приложение через несколько дней и попробуйте снова.
Спасибо за терпение!"</string>
<string name="screen_waitlist_message_success">"Добро пожаловать в %1$s!"</string>
<string name="screen_waitlist_title">"Почти готово."</string>
<string name="screen_waitlist_title_success">"Вы зарегистрированы."</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Môžete sa pripojiť iba k existujúcemu serveru, ktorý podporuje kĺzavú synchronizáciu. Správca domovského servera ju bude musieť nakonfigurovať. %1$s"</string>
<string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string>
<string name="screen_change_server_title">"Vyberte svoj server"</string>
<string name="screen_create_account_title">"Vytvoriť účet"</string>
<string name="screen_login_error_deactivated_account">"Tento účet bol deaktivovaný."</string>
<string name="screen_login_error_invalid_credentials">"Nesprávne používateľské meno a/alebo heslo"</string>
<string name="screen_login_error_invalid_user_id">"Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'"</string>
@ -78,10 +79,4 @@ Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariade
<string name="screen_server_confirmation_message_register">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
<string name="screen_server_confirmation_title_login">"Chystáte sa prihlásiť do %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte sa vytvoriť účet na %1$s"</string>
<string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova.
Ďakujeme za trpezlivosť!"</string>
<string name="screen_waitlist_message_success">"Vitajte v %1$s!"</string>
<string name="screen_waitlist_title">"Ste na čakanej listine!"</string>
<string name="screen_waitlist_title_success">"Ste dnu!"</string>
</resources>

View file

@ -78,10 +78,4 @@ Prova att logga in manuellt eller skanna QR-koden med en annan enhet."</string>
<string name="screen_server_confirmation_message_register">"Det är här dina konversationer kommer att sparas - precis som du skulle använda en e-postleverantör för att spara dina e-brev."</string>
<string name="screen_server_confirmation_title_login">"Du är på väg att logga in på %1$s"</string>
<string name="screen_server_confirmation_title_register">"Du är på väg att skapa ett konto på %1$s"</string>
<string name="screen_waitlist_message">"Det finns en stor efterfrågan på %1$s på %2$s just nu. Kom tillbaka till appen om några dagar och försök igen.
Tack för ditt tålamod!"</string>
<string name="screen_waitlist_message_success">"Välkommen till %1$s!"</string>
<string name="screen_waitlist_title">"Du är nästan framme."</string>
<string name="screen_waitlist_title_success">"Du är inne."</string>
</resources>

View file

@ -78,10 +78,4 @@
<string name="screen_server_confirmation_message_register">"Тут будуть зберігатися Ваші розмови - так само, як Ви використовуєте поштову скриньку для зберігання своїх електронних листів."</string>
<string name="screen_server_confirmation_title_login">"Ви збираєтесь увійти в %1$s"</string>
<string name="screen_server_confirmation_title_register">"Ви збираєтеся створити обліковий запис на %1$s"</string>
<string name="screen_waitlist_message">"На цей момент існує високий попит на %1$s в %2$s. Поверніться до застосунку через кілька днів і спробуйте ще раз.
Дякуємо за терпіння!"</string>
<string name="screen_waitlist_message_success">"Ласкаво просимо до %1$s!"</string>
<string name="screen_waitlist_title">"Майже готово."</string>
<string name="screen_waitlist_title_success">"Готово."</string>
</resources>

View file

@ -33,10 +33,4 @@
<string name="screen_server_confirmation_message_register">"Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi."</string>
<string name="screen_server_confirmation_title_login">"Siz tizimga kirmoqchisiz%1$s"</string>
<string name="screen_server_confirmation_title_register">"Hisob yaratmoqchisiz%1$s"</string>
<string name="screen_waitlist_message">"Hozirgi paytda %2$sga %1$sda talab yuqori. Bir necha kundan keyin ilovaga qayting va qaytadan urining.
Sabr-toqatingiz uchun rahmat!"</string>
<string name="screen_waitlist_message_success">"%1$sga Xush kelibsiz!"</string>
<string name="screen_waitlist_title">"Siz deyarli keldingiz."</string>
<string name="screen_waitlist_title_success">"Siz kirdingiz."</string>
</resources>

View file

@ -29,5 +29,4 @@
<string name="screen_server_confirmation_message_register">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string>
<string name="screen_server_confirmation_title_login">"您即將登入 %1$s"</string>
<string name="screen_server_confirmation_title_register">"您即將在 %1$s 建立帳號"</string>
<string name="screen_waitlist_message_success">"歡迎使用 %1$s"</string>
</resources>

View file

@ -78,10 +78,4 @@
<string name="screen_server_confirmation_message_register">"这是您的对话将进行的地方,就像您使用电子邮件提供商来保存电子邮件一样。"</string>
<string name="screen_server_confirmation_title_login">"即将登录 %1$s"</string>
<string name="screen_server_confirmation_title_register">"即将在 %1$s 上创建一个账户"</string>
<string name="screen_waitlist_message">"目前 %1$s 上 %2$s 的负载很大。过几天再回来试试吧。
感谢您的耐心!"</string>
<string name="screen_waitlist_message_success">"欢迎使用 %1$s"</string>
<string name="screen_waitlist_title">"马上就好。"</string>
<string name="screen_waitlist_title_success">"您已加入。"</string>
</resources>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s"</string>
<string name="screen_change_server_subtitle">"What is the address of your server?"</string>
<string name="screen_change_server_title">"Select your server"</string>
<string name="screen_create_account_title">"Create account"</string>
<string name="screen_login_error_deactivated_account">"This account has been deactivated."</string>
<string name="screen_login_error_invalid_credentials">"Incorrect username and/or password"</string>
<string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: @user:homeserver.org"</string>
@ -78,10 +79,4 @@ Try signing in manually, or scan the QR code with another device."</string>
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
<string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again.
Thanks for your patience!"</string>
<string name="screen_waitlist_message_success">"Welcome to %1$s!"</string>
<string name="screen_waitlist_title">"Youre almost there."</string>
<string name="screen_waitlist_title_success">"You\'re in."</string>
</resources>

View file

@ -13,7 +13,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.A_HOMESERVER
@ -255,17 +258,109 @@ class ConfirmAccountProviderPresenterTest {
}
}
@Test
fun `present - confirm account creation without oidc and without url generates an error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
authenticationService.givenHomeserver(A_HOMESERVER)
val presenter = createConfirmAccountProviderPresenter(
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
matrixAuthenticationService = authenticationService,
webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever {
throw AccountCreationNotSupported()
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginFlow.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginFlow).isEqualTo(AsyncData.Uninitialized)
}
}
@Test
fun `present - confirm account creation with oidc is successful`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
val presenter = createConfirmAccountProviderPresenter(
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
matrixAuthenticationService = authenticationService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
}
}
@Test
fun `present - confirm account creation with oidc and url continues with oidc`() = runTest {
val aUrl = "aUrl"
val authenticationService = FakeMatrixAuthenticationService()
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
val presenter = createConfirmAccountProviderPresenter(
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
matrixAuthenticationService = authenticationService,
webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
}
}
@Test
fun `present - confirm account creation without oidc and with url continuing with url`() = runTest {
val aUrl = "aUrl"
val authenticationService = FakeMatrixAuthenticationService()
authenticationService.givenHomeserver(A_HOMESERVER)
val presenter = createConfirmAccountProviderPresenter(
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
matrixAuthenticationService = authenticationService,
webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.AccountCreationFlow(aUrl))
}
}
private fun createConfirmAccountProviderPresenter(
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
authenticationService = matrixAuthenticationService,
oidcActionFlow = defaultOidcActionFlow,
defaultLoginUserStory = defaultLoginUserStory,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever
)
}

View file

@ -0,0 +1,145 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class CreateAccountPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo("aUrl")
assertThat(initialState.pageProgress).isEqualTo(0)
assertThat(initialState.createAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.isDebugBuild).isTrue()
}
}
@Test
fun `present - set up progress update the state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateAccountEvents.SetPageProgress(33))
assertThat(awaitItem().pageProgress).isEqualTo(33)
}
}
@Test
fun `present - receiving a message not able to be parsed change the state to error`() = runTest {
val presenter = createPresenter(
messageParser = FakeMessageParser { error("An error") }
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateAccountEvents.OnMessageReceived(""))
assertThat(awaitItem().createAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
@Test
fun `present - receiving a message containing isTrusted is ignored`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateAccountEvents.OnMessageReceived("isTrusted"))
}
}
@Test
fun `present - receiving a message able to be parsed change the state to success`() = runTest {
val defaultLoginUserStory = DefaultLoginUserStory()
defaultLoginUserStory.setLoginFlowIsDone(false)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
val lambda = lambdaRecorder<String, ExternalSession> { _ -> anExternalSession() }
val presenter = createPresenter(
authenticationService = FakeMatrixAuthenticationService(
importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
),
messageParser = FakeMessageParser(lambda),
defaultLoginUserStory = defaultLoginUserStory,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage"))
assertThat(awaitItem().createAction.isLoading()).isTrue()
assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID)
}
lambda.assertions().isCalledOnce().with(value("aMessage"))
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isTrue()
}
@Test
fun `present - receiving a message able to be parsed but error in importing change the state to error`() = runTest {
val defaultLoginUserStory = DefaultLoginUserStory()
defaultLoginUserStory.setLoginFlowIsDone(false)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
val presenter = createPresenter(
authenticationService = FakeMatrixAuthenticationService(
importCreatedSessionLambda = { Result.failure(AN_EXCEPTION) }
),
messageParser = FakeMessageParser { anExternalSession() }
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateAccountEvents.OnMessageReceived(""))
assertThat(awaitItem().createAction.isLoading()).isTrue()
assertThat(awaitItem().createAction.errorOrNull()).isNotNull()
}
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
}
private fun createPresenter(
url: String = "aUrl",
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
messageParser: MessageParser = FakeMessageParser(),
buildMeta: BuildMeta = aBuildMeta(),
) = CreateAccountPresenter(
url = url,
authenticationService = authenticationService,
defaultLoginUserStory = defaultLoginUserStory,
messageParser = messageParser,
buildMeta = buildMeta,
)
}

View file

@ -0,0 +1,84 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import kotlinx.serialization.SerializationException
import org.junit.Assert.assertThrows
import org.junit.Test
class DefaultMessageParserTest {
private val validMessage = """
{
"user_id": "user_id",
"home_server": "home_server",
"access_token": "access_token",
"device_id": "device_id"
}
""".trimIndent()
@Test
fun `DefaultMessageParser is able to parse correct message`() {
val sut = DefaultMessageParser(
AccountProviderDataSource()
)
assertThat(sut.parse(validMessage)).isEqualTo(
anExternalSession(
homeserverUrl = "home_server",
)
)
}
@Test
fun `DefaultMessageParser should throw Exception in case of error`() {
val sut = DefaultMessageParser(
AccountProviderDataSource()
)
// kotlinx.serialization.json.internal.JsonDecodingException
assertThrows(SerializationException::class.java) { sut.parse("invalid json") }
// missing userId
assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""user_id": "user_id",""", "")) }
// missing accessToken
assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""access_token": "access_token",""", "")) }
// missing deviceId
assertThrows(IllegalStateException::class.java) {
sut.parse(
validMessage
.replace(""""access_token": "access_token",""", """"access_token": "access_token"""")
.replace(""""device_id": "device_id"""", "")
)
}
}
@Test
fun `DefaultMessageParser should be successful even is homeserver url is missing`() {
val sut = DefaultMessageParser(
AccountProviderDataSource()
)
// missing homeServer
assertThat(sut.parse(validMessage.replace(""""home_server": "home_server",""", ""))).isEqualTo(
anExternalSession(
homeserverUrl = defaultAccountProvider.url,
)
)
}
}
internal fun anExternalSession(
homeserverUrl: String = "home_server",
) = ExternalSession(
userId = "user_id",
homeserverUrl = homeserverUrl,
accessToken = "access_token",
deviceId = "device_id",
refreshToken = null,
slidingSyncProxy = null
)

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMessageParser(
private val parseResult: (String) -> ExternalSession = { lambdaError() }
) : MessageParser {
override fun parse(message: String): ExternalSession {
return parseResult(message)
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.login.impl.web
import io.element.android.tests.testutils.lambda.lambdaError
class FakeWebClientUrlForAuthenticationRetriever(
private val retrieveLambda: suspend (homeServerUrl: String) -> String = { lambdaError() }
) : WebClientUrlForAuthenticationRetriever {
override suspend fun retrieve(homeServerUrl: String): String {
return retrieveLambda(homeServerUrl)
}
}

View file

@ -241,6 +241,9 @@ fun MessagesView(
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
},
onEmojiReactionClick = ::onEmojiReactionClick,
onVerifiedUserSendFailureClick = { event ->
state.timelineState.eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
},
)
CustomReactionBottomSheet(

View file

@ -22,6 +22,8 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -56,6 +58,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
private val appPreferencesStore: AppPreferencesStore,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val room: MatrixRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
@ -115,12 +118,14 @@ class DefaultActionListPresenter @AssistedInject constructor(
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
)
val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) {
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) {
target.value = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = displayEmojiReactions,
verifiedUserSendFailure = verifiedUserSendFailure,
actions = actions.toImmutableList()
)
} else {
@ -190,9 +195,9 @@ private fun List<TimelineItemAction>.postFilter(content: TimelineItemEventConten
when (content) {
is TimelineItemCallNotifyContent,
is TimelineItemLegacyCallInviteContent,
is TimelineItemStateContent,
is TimelineItemStateContent -> action == TimelineItemAction.ViewSource
is TimelineItemRedactedContent -> {
action == TimelineItemAction.ViewSource
action == TimelineItemAction.ViewSource || action == TimelineItemAction.Unpin
}
else -> true
}

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import kotlinx.collections.immutable.ImmutableList
@ -17,12 +18,14 @@ data class ActionListState(
val target: Target,
val eventSink: (ActionListEvents) -> Unit,
) {
@Immutable
sealed interface Target {
data object None : Target
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val displayEmojiReactions: Boolean,
val verifiedUserSendFailure: VerifiedUserSendFailure,
val actions: ImmutableList<TimelineItemAction>,
) : Target
}

View file

@ -9,6 +9,8 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
@ -35,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -47,6 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState,
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -56,6 +60,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -65,6 +70,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -74,6 +80,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -83,6 +90,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -92,6 +100,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -101,6 +110,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
),
),
@ -110,6 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemPollActionList(),
),
),
@ -120,6 +131,15 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
messageShield = MessageShield.UnknownDevice(isCritical = true)
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(),
displayEmojiReactions = true,
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
actions = aTimelineItemActionList(),
)
),

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -46,6 +47,10 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -90,6 +95,7 @@ fun ActionListView(
onSelectAction: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
onEmojiReactionClick: (String, TimelineItem.Event) -> Unit,
onCustomReactionClick: (TimelineItem.Event) -> Unit,
onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
@ -126,6 +132,14 @@ fun ActionListView(
state.eventSink(ActionListEvents.Clear)
}
fun onVerifiedUserSendFailureClick() {
if (targetItem == null) return
sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
onVerifiedUserSendFailureClick(targetItem)
}
}
if (targetItem != null) {
ModalBottomSheet(
sheetState = sheetState,
@ -137,6 +151,7 @@ fun ActionListView(
onActionClick = ::onItemActionClick,
onEmojiReactionClick = ::onEmojiReactionClick,
onCustomReactionClick = ::onCustomReactionClick,
onVerifiedUserSendFailureClick = ::onVerifiedUserSendFailureClick,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
@ -151,6 +166,7 @@ private fun SheetContent(
onActionClick: (TimelineItemAction) -> Unit,
onEmojiReactionClick: (String) -> Unit,
onCustomReactionClick: () -> Unit,
onVerifiedUserSendFailureClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (val target = state.target) {
@ -184,6 +200,16 @@ private fun SheetContent(
HorizontalDivider()
}
}
if (target.verifiedUserSendFailure != None) {
item {
VerifiedUserSendFailureView(
sendFailure = target.verifiedUserSendFailure,
modifier = Modifier.fillMaxWidth(),
onClick = onVerifiedUserSendFailureClick
)
HorizontalDivider()
}
}
if (target.displayEmojiReactions) {
item {
EmojiReactionsRow(
@ -338,6 +364,42 @@ private fun EmojiReactionsRow(
}
}
@Composable
private fun VerifiedUserSendFailureView(
sendFailure: VerifiedUserSendFailure,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@Composable
fun VerifiedUserSendFailure.headline(): String {
return when (this) {
is None -> ""
is UnsignedDevice -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_unsigned_device, userDisplayName)
is ChangedIdentity -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, userDisplayName)
}
}
ListItem(
modifier = modifier
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Error())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
headlineContent = {
Text(
text = sendFailure.headline(),
style = ElementTheme.typography.fontBodySmMedium,
)
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
leadingIconColor = ElementTheme.colors.iconCriticalPrimary,
trailingIconColor = ElementTheme.colors.iconPrimary,
headlineColor = ElementTheme.colors.textCriticalPrimary,
),
)
}
@Composable
private fun EmojiButton(
emoji: String,
@ -387,5 +449,6 @@ internal fun SheetContentPreview(
onActionClick = {},
onEmojiReactionClick = {},
onCustomReactionClick = {},
onVerifiedUserSendFailureClick = {},
)
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure
import androidx.compose.runtime.Immutable
@Immutable
sealed interface VerifiedUserSendFailure {
data object None : VerifiedUserSendFailure
data class UnsignedDevice(
val userDisplayName: String,
) : VerifiedUserSendFailure
data class ChangedIdentity(
val userDisplayName: String,
) : VerifiedUserSendFailure
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import javax.inject.Inject
class VerifiedUserSendFailureFactory @Inject constructor(
private val room: MatrixRoom,
) {
suspend fun create(
sendState: LocalEventSendState?,
): VerifiedUserSendFailure {
return when (sendState) {
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> {
val userId = sendState.devices.keys.firstOrNull()
if (userId == null) {
VerifiedUserSendFailure.None
} else {
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
VerifiedUserSendFailure.UnsignedDevice(displayName)
}
}
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> {
val userId = sendState.users.firstOrNull()
if (userId == null) {
VerifiedUserSendFailure.None
} else {
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
VerifiedUserSendFailure.ChangedIdentity(displayName)
}
}
else -> VerifiedUserSendFailure.None
}
}
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ResolveVerifiedUserSendFailureEvents {
data class ComputeForMessage(
val messageEvent: TimelineItem.Event,
) : ResolveVerifiedUserSendFailureEvents
data object ResolveAndResend : ResolveVerifiedUserSendFailureEvents
data object Retry : ResolveVerifiedUserSendFailureEvents
data object Dismiss : ResolveVerifiedUserSendFailureEvents
}

View file

@ -0,0 +1,95 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.coroutines.launch
import javax.inject.Inject
class ResolveVerifiedUserSendFailurePresenter @Inject constructor(
private val room: MatrixRoom,
private val verifiedUserSendFailureFactory: VerifiedUserSendFailureFactory,
) : Presenter<ResolveVerifiedUserSendFailureState> {
@Composable
override fun present(): ResolveVerifiedUserSendFailureState {
var resolver by remember {
mutableStateOf<VerifiedUserSendFailureResolver?>(null)
}
val verifiedUserSendFailure by produceState<VerifiedUserSendFailure>(VerifiedUserSendFailure.None, resolver?.currentSendFailure?.value) {
val currentSendFailure = resolver?.currentSendFailure?.value
value = verifiedUserSendFailureFactory.create(currentSendFailure)
}
val resolveAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val retryAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: ResolveVerifiedUserSendFailureEvents) {
when (event) {
is ResolveVerifiedUserSendFailureEvents.ComputeForMessage -> {
val sendState = event.messageEvent.localSendState as? LocalEventSendState.Failed.VerifiedUser
val transactionId = event.messageEvent.transactionId
resolver = if (sendState != null && transactionId != null) {
VerifiedUserSendFailureResolver(
room = room,
transactionId = transactionId,
iterator = VerifiedUserSendFailureIterator.from(sendState)
)
} else {
null
}
}
ResolveVerifiedUserSendFailureEvents.Dismiss -> {
resolver = null
}
ResolveVerifiedUserSendFailureEvents.Retry -> {
coroutineScope.launch {
resolver?.run {
runUpdatingState(retryAction) {
resend()
}
}
}
}
ResolveVerifiedUserSendFailureEvents.ResolveAndResend -> {
coroutineScope.launch {
resolver?.run {
runUpdatingState(resolveAction) {
resolveAndResend()
}
}
}
}
}
}
return ResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = verifiedUserSendFailure,
resolveAction = resolveAction.value,
retryAction = retryAction.value,
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.architecture.AsyncAction
data class ResolveVerifiedUserSendFailureState(
val verifiedUserSendFailure: VerifiedUserSendFailure,
val resolveAction: AsyncAction<Unit>,
val retryAction: AsyncAction<Unit>,
val eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit
)

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.architecture.AsyncAction
open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider<ResolveVerifiedUserSendFailureState> {
override val values: Sequence<ResolveVerifiedUserSendFailureState>
get() = sequenceOf(
aResolveVerifiedUserSendFailureState(),
aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = anUnsignedDeviceSendFailure()
),
aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = aChangedIdentitySendFailure()
)
)
}
fun aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure: VerifiedUserSendFailure = VerifiedUserSendFailure.None,
resolveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
retryAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit = {}
) = ResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = verifiedUserSendFailure,
resolveAction = resolveAction,
retryAction = retryAction,
eventSink = eventSink
)
fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice(
userDisplayName = userDisplayName,
)
fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity(
userDisplayName = userDisplayName,
)

View file

@ -0,0 +1,156 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showSheet by remember { mutableStateOf(false) }
fun dismiss() {
state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss)
}
fun onRetryClick() {
state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry)
}
fun onResolveAndResendClick() {
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
LaunchedEffect(state.verifiedUserSendFailure) {
if (state.verifiedUserSendFailure is VerifiedUserSendFailure.None) {
sheetState.hide()
showSheet = false
} else {
showSheet = true
}
}
Box(modifier = modifier) {
if (showSheet) {
ModalBottomSheet(
modifier = Modifier
.systemBarsPadding()
.navigationBarsPadding(),
sheetState = sheetState,
onDismissRequest = ::dismiss,
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(24.dp),
title = state.verifiedUserSendFailure.title(),
subTitle = state.verifiedUserSendFailure.subtitle(),
iconImageVector = CompoundIcons.Error(),
iconTint = ElementTheme.colors.iconCriticalPrimary,
iconBackgroundTint = ElementTheme.colors.bgCriticalSubtle,
)
ButtonColumnMolecule(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Button(
modifier = Modifier.fillMaxWidth(),
text = state.verifiedUserSendFailure.resolveAction(),
showProgress = state.resolveAction.isLoading(),
onClick = ::onResolveAndResendClick
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = CommonStrings.action_retry),
showProgress = state.retryAction.isLoading(),
onClick = ::onRetryClick
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = CommonStrings.action_cancel_for_now),
onClick = ::dismiss,
)
}
}
}
}
}
@Composable
private fun VerifiedUserSendFailure.title(): String {
return when (this) {
is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_title, userDisplayName)
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(
id = CommonStrings.screen_resolve_send_failure_changed_identity_title,
userDisplayName
)
VerifiedUserSendFailure.None -> error("This method should never be called for this state")
}
}
@Composable
private fun VerifiedUserSendFailure.subtitle(): String {
return when (this) {
is VerifiedUserSendFailure.UnsignedDevice -> stringResource(
id = CommonStrings.screen_resolve_send_failure_unsigned_device_subtitle,
userDisplayName,
userDisplayName,
)
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(
id = CommonStrings.screen_resolve_send_failure_changed_identity_subtitle,
userDisplayName
)
VerifiedUserSendFailure.None -> error("This method should never be called for this state")
}
}
@Composable
private fun VerifiedUserSendFailure.resolveAction(): String {
return when (this) {
is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_primary_button_title)
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(id = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title)
VerifiedUserSendFailure.None -> error("This method should never be called for this state")
}
}
@PreviewsDayNight
@Composable
internal fun ResolveVerifiedUserSendFailureViewPreview(
@PreviewParameter(ResolveVerifiedUserSendFailureStateProvider::class) state: ResolveVerifiedUserSendFailureState
) = ElementPreview {
ResolveVerifiedUserSendFailureView(state)
}

View file

@ -0,0 +1,73 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import timber.log.Timber
/**
* Iterator for [LocalEventSendState.Failed.VerifiedUser]
* Allow to iterate through the internal state of the failure.
* This is useful to allow solving the failure step by step (e.g. for each user).
*/
interface VerifiedUserSendFailureIterator : Iterator<LocalEventSendState.Failed.VerifiedUser> {
companion object {
fun from(failure: LocalEventSendState.Failed.VerifiedUser): VerifiedUserSendFailureIterator {
return when (failure) {
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> UnsignedDeviceSendFailureIterator(failure)
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> ChangedIdentitySendFailureIterator(failure)
}
}
}
}
class UnsignedDeviceSendFailureIterator(
failure: LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice
) : VerifiedUserSendFailureIterator {
private val iterator = failure.devices.iterator()
init {
if (!hasNext()) {
Timber.w("Got $failure without any devices, shouldn't happen.")
}
}
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): LocalEventSendState.Failed.VerifiedUser {
val (userId, deviceIds) = iterator.next()
return LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice(
mapOf(userId to deviceIds)
)
}
}
class ChangedIdentitySendFailureIterator(
failure: LocalEventSendState.Failed.VerifiedUserChangedIdentity
) : VerifiedUserSendFailureIterator {
private val iterator = failure.users.iterator()
init {
if (!hasNext()) {
Timber.w("Got $failure without any users, shouldn't happen.")
}
}
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): LocalEventSendState.Failed.VerifiedUser {
val userId = iterator.next()
return LocalEventSendState.Failed.VerifiedUserChangedIdentity(
listOf(userId)
)
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.compose.runtime.mutableStateOf
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import timber.log.Timber
/**
* This class is responsible for resolving and resending a failed message sent to a verified user.
* It also allow to resend the message without resolving the failure, for example if the user has in the meantime verified their device again.
* It's using the [VerifiedUserSendFailureIterator] to iterate over the different failures (ie. the different users concerned by the failure).
* This way, the user can resolve and resend the message for each user concerned, one by one.
*/
class VerifiedUserSendFailureResolver(
private val room: MatrixRoom,
private val transactionId: TransactionId,
private val iterator: VerifiedUserSendFailureIterator,
) {
val currentSendFailure = mutableStateOf<LocalEventSendState.Failed.VerifiedUser?>(null)
init {
if (iterator.hasNext()) {
currentSendFailure.value = iterator.next()
}
}
suspend fun resend(): Result<Unit> {
return room.retrySendMessage(transactionId)
.onSuccess {
Timber.d("Succeed to resend message with transactionId: $transactionId")
currentSendFailure.value = null
}
.onFailure {
Timber.e(it, "Failed to resend message with transactionId: $transactionId")
}
}
suspend fun resolveAndResend(): Result<Unit> {
return when (val failure = currentSendFailure.value) {
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> {
room.ignoreDeviceTrustAndResend(failure.devices, transactionId)
}
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> {
room.withdrawVerificationAndResend(failure.users, transactionId)
}
else -> {
Result.failure(IllegalStateException("Unknown send failure type"))
}
}.onSuccess {
Timber.d("Succeed to resolve and resend message with transactionId: $transactionId")
if (iterator.hasNext()) {
val failure = iterator.next()
currentSendFailure.value = failure
} else {
currentSendFailure.value = null
Timber.d("No more failure to resolve for transactionId: $transactionId")
}
}.onFailure {
Timber.e(it, "Failed to resolve and resend message with transactionId: $transactionId")
}
}
}

View file

@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.libraries.architecture.Presenter
@ -20,4 +22,7 @@ import io.element.android.libraries.di.RoomScope
interface MessagesModule {
@Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
@Binds
fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter<ResolveVerifiedUserSendFailureState>
}

View file

@ -119,7 +119,6 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
targetEvent: TimelineItem.Event,
) = launch {
when (action) {
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.ViewSource -> {
navigator.onShowEventDebugInfoClick(targetEvent.eventId, targetEvent.debugInfo)
}
@ -149,13 +148,6 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
}
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
timelineProvider.invokeOnTimeline {
redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null)
.onFailure { Timber.e(it) }
}
}
@Composable
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {

View file

@ -17,7 +17,6 @@ class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProc
actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.Redact }?.let(::add)
}
}
}

View file

@ -108,7 +108,7 @@ private fun PinnedMessagesListContent(
ErrorDialog(
title = stringResource(id = CommonStrings.error_unknown),
content = stringResource(id = CommonStrings.error_failed_loading_messages),
onDismiss = onErrorDismiss
onSubmit = onErrorDismiss
)
}
PinnedMessagesListState.Empty -> PinnedMessagesListEmpty()
@ -181,6 +181,7 @@ private fun PinnedMessagesListLoaded(
onSelectAction = ::onActionSelected,
onCustomReactionClick = {},
onEmojiReactionClick = { _, _ -> },
onVerifiedUserSendFailureClick = {}
)
LazyColumn(
modifier = modifier.fillMaxSize(),
@ -199,19 +200,18 @@ private fun PinnedMessagesListLoaded(
renderReadReceipts = false,
isLastOutgoingMessage = false,
focusedEventId = null,
onClick = onEventClick,
onLongClick = ::onMessageLongClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onClick = onEventClick,
onLongClick = ::onMessageLongClick,
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
onSwipeToReply = {},
onJoinCallClick = {},
onShieldClick = {},
eventSink = {},
eventContentView = { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentViewWrapper(
event = event,

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -19,7 +20,6 @@ sealed interface TimelineEvents {
data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents
data class ShowShieldDialog(val messageShield: MessageShield) : TimelineEvents
data object HideShieldDialog : TimelineEvents
/**
@ -27,6 +27,8 @@ sealed interface TimelineEvents {
*/
sealed interface EventFromTimelineItem : TimelineEvents
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem
data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
/**

View file

@ -21,6 +21,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState
@ -66,6 +68,7 @@ class TimelinePresenter @AssistedInject constructor(
private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore,
private val timelineController: TimelineController,
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
@ -101,6 +104,7 @@ class TimelinePresenter @AssistedInject constructor(
val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
val isLive by timelineController.isLive().collectAsState(initial = true)
@ -156,6 +160,9 @@ class TimelinePresenter @AssistedInject constructor(
}
TimelineEvents.HideShieldDialog -> messageShield.value = null
is TimelineEvents.ShowShieldDialog -> messageShield.value = event.messageShield
is TimelineEvents.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(event.event))
}
}
}
@ -232,6 +239,7 @@ class TimelinePresenter @AssistedInject constructor(
isLive = isLive,
focusRequestState = focusRequestState.value,
messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
eventSink = { handleEvents(it) }
)
}

Some files were not shown because too many files have changed in this diff Show more