Merge branch 'develop' into feature/valere/timeline_active_call_rendering

This commit is contained in:
Valere
2026-07-13 14:54:16 +02:00
472 changed files with 5177 additions and 2089 deletions
+4 -4
View File
@@ -69,18 +69,18 @@ body:
description: |
Trigger the defect, then click on the menu from the room list then "Report a bug". Describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
options:
- 'Yes'
- 'No'
- 'Yes'
validations:
required: true
- type: dropdown
id: pr
attributes:
label: Are you willing to provide a PR?
label: Will you submit a Pull Request?
description: |
Providing a PR can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
Submitting a Pull Request can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
options:
- 'Yes'
- 'No'
- 'Yes'
validations:
required: true
+2 -2
View File
@@ -1,11 +1,11 @@
name: Enhancement request
description: Do you have a suggestion or feature request?
description: Do you have a suggestion or feature request? Please open issue on the repo element-hq/element-meta if the request is global to all Element clients.
labels: [T-Enhancement]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/element-hq/element-meta/discussions/new?category=ideas).
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please open an issue on the [element-meta repository](https://github.com/element-hq/element-meta/issues/new?template=enhancement.yml) instead.
- type: textarea
id: usecase
attributes:
+12 -1
View File
@@ -104,7 +104,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources
if: ${{ matrix.variant == 'release' }}
run: ./gradlew bundleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
run: |
./gradlew assembleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
./gradlew bundleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Run aapt dump
if: ${{ matrix.variant == 'release' }}
run: |
$ANDROID_HOME/build-tools/37.0.0/aapt dump badging \
./app/build/outputs/apk/gplay/release/app-gplay-universal-release.apk \
> /tmp/currentAaptDump.txt
- name: Check manifest has not changed
if: ${{ matrix.variant == 'release' }}
run: diff /tmp/currentAaptDump.txt ./tools/manifest/gplay/release/aaptDump.txt
- name: Compile nightly sources
if: ${{ matrix.variant == 'nightly' }}
run: ./gradlew compileGplayNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
+1
View File
@@ -39,6 +39,7 @@ jobs:
./tools/localazy/downloadStrings.sh --all
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
./tools/manifest/aaptDump.sh
- name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
-3
View File
@@ -8,9 +8,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- To be able to install APK from the application -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".ElementXApplication"
android:allowBackup="false"
+2
View File
@@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.oauth.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
@@ -61,6 +62,7 @@ dependencies {
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
@@ -12,4 +12,7 @@ sealed interface LoggedInEvents {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
data object CheckSlidingSyncProxyAvailability : LoggedInEvents
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvents
data object DismissLocalNetworkPermissionPrompt : LoggedInEvents
data object RequestLocationNetworkPermission : LoggedInEvents
}
@@ -8,6 +8,7 @@
package io.element.android.appnav.loggedin
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -15,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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
@@ -36,6 +38,10 @@ import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.services.analytics.api.AnalyticsService
@@ -58,7 +64,12 @@ class LoggedInPresenter(
private val encryptionService: EncryptionService,
private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter =
permissionsPresenterFactory.create(Manifest.permission.ACCESS_LOCAL_NETWORK)
@Composable
override fun present(): LoggedInState {
val coroutineScope = rememberCoroutineScope()
@@ -117,6 +128,21 @@ class LoggedInPresenter(
}
}
val localNetworkPermissionState = localNetworkPermissionsPresenter.present()
var localNetworkPromptDismissedThisSession by remember { mutableStateOf(false) }
val advisorRequestsPrompt by produceState(initialValue = false, localNetworkPermissionState.permissionGranted) {
value = localNetworkPermissionAdvisor.shouldRequestPermissionFor(matrixClient.homeserverUrl)
}
LaunchedEffect(localNetworkPermissionState.permissionGranted) {
if (localNetworkPermissionState.permissionGranted) localNetworkPromptDismissedThisSession = false
}
val localNetworkPermissionDialog = when {
!advisorRequestsPrompt -> LocalNetworkPermissionDialog.None
localNetworkPromptDismissedThisSession -> LocalNetworkPermissionDialog.None
localNetworkPermissionState.shouldShowRationale -> LocalNetworkPermissionDialog.Rationale
else -> LocalNetworkPermissionDialog.Settings
}
fun handleEvent(event: LoggedInEvents) {
when (event) {
is LoggedInEvents.CloseErrorDialog -> {
@@ -134,6 +160,17 @@ class LoggedInPresenter(
// Force the logout since Native Sliding Sync is already enforced by the SDK
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
}
LoggedInEvents.DismissLocalNetworkPermissionPrompt -> {
localNetworkPromptDismissedThisSession = true
}
LoggedInEvents.RequestLocationNetworkPermission -> {
if (localNetworkPermissionDialog == LocalNetworkPermissionDialog.Settings) {
localNetworkPermissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
} else {
localNetworkPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
localNetworkPromptDismissedThisSession = true
}
}
}
@@ -143,6 +180,7 @@ class LoggedInPresenter(
ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = buildMeta.applicationName,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = ::handleEvent,
)
}
@@ -9,6 +9,7 @@
package io.element.android.appnav.loggedin
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class LoggedInState(
val showSyncSpinner: Boolean,
@@ -16,5 +17,6 @@ data class LoggedInState(
val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean,
val appName: String,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (LoggedInEvents) -> Unit,
)
@@ -10,6 +10,7 @@ package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.push.api.PusherRegistrationFailure
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
@@ -19,6 +20,8 @@ open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
aLoggedInState(showSyncSpinner = true),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
aLoggedInState(forceNativeSlidingSyncMigration = true),
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Settings),
)
}
@@ -27,11 +30,13 @@ fun aLoggedInState(
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false,
appName: String = "Element X",
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = appName,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = {},
)
@@ -25,6 +25,7 @@ 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.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.libraries.ui.strings.CommonStrings
@@ -82,6 +83,12 @@ fun LoggedInView(
}
)
}
LocalNetworkPermissionDialogView(
dialog = state.localNetworkPermissionDialog,
onSubmit = { state.eventSink(LoggedInEvents.RequestLocationNetworkPermission) },
onDismiss = { state.eventSink(LoggedInEvents.DismissLocalNetworkPermissionPrompt) },
)
}
private fun Throwable.getReason(): String? {
@@ -36,6 +36,8 @@ 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.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.libraries.push.test.FakePushService
@@ -124,6 +126,8 @@ class LoggedInPresenterTest {
encryptionService = encryptionService,
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
@@ -368,6 +372,8 @@ class LoggedInPresenterTest {
encryptionService = encryptionService,
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
)
}
}
@@ -14,6 +14,7 @@
android:name="android.hardware.microphone"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
@@ -3,14 +3,34 @@
<string name="screen_create_room_action_create_room">"Ruangan baru"</string>
<string name="screen_create_room_add_people_title">"Undang orang-orang"</string>
<string name="screen_create_room_error_creating_room">"Terjadi kesalahan saat membuat ruangan"</string>
<string name="screen_create_room_error_creating_space">"Ruang tersebut tidak dapat dibuat karena kesalahan yang tidak diketahui. Coba lagi nanti."</string>
<string name="screen_create_room_name_placeholder">"Tambahkan nama…"</string>
<string name="screen_create_room_new_room_title">"Ruangan baru"</string>
<string name="screen_create_room_new_space_title">"Ruang baru"</string>
<string name="screen_create_room_private_option_description">"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."</string>
<string name="screen_create_room_private_option_title">"Pribadi"</string>
<string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string>
<string name="screen_create_room_public_option_short_description">"Siapa pun bisa bergabung."</string>
<string name="screen_create_room_public_option_title">"Umum"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Siapa pun dapat meminta untuk bergabung dengan ijin administrator atau moderator"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Minta untuk bergabung"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Siapa pun di %1$s dapat bergabung, tetapi semua orang lain harus meminta akses."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Ajukan permohonan untuk bergabung"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Pribadi"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Siapa pun dapat bergabung dengan ruangan ini"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Umum"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Siapa pun di %1$s dapat bergabung."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standar"</string>
<string name="screen_create_room_room_access_section_title">"Yang memiliki akses"</string>
<string name="screen_create_room_room_address_section_footer">"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."</string>
<string name="screen_create_room_room_address_section_title">"Alamat ruangan"</string>
<string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string>
<string name="screen_create_room_space_selection_no_space_description">"(ruang sudah penuh)"</string>
<string name="screen_create_room_space_selection_no_space_option">"Jangan menambahkan sesuatu ke ruang yang sudah ada."</string>
<string name="screen_create_room_space_selection_no_space_title">"Tidak ada ruang yang dipilih"</string>
<string name="screen_create_room_space_selection_sheet_title">"Tambahkan ke ruang"</string>
<string name="screen_create_room_topic_label">"Topik (opsional)"</string>
<string name="screen_create_room_topic_placeholder">"Tambahkan deskripsi…"</string>
</resources>
@@ -23,7 +23,7 @@
<string name="screen_create_room_room_access_section_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s にいる全員が参加することができます。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"スタンダード"</string>
<string name="screen_create_room_room_access_section_title">"参加できるユーザー"</string>
<string name="screen_create_room_room_access_section_title">"参加可能なユーザー"</string>
<string name="screen_create_room_room_address_section_footer">"公開ディレクトリで自分を見つけられるようにするには、アドレスが必要です。"</string>
<string name="screen_create_room_room_address_section_title">"アドレス"</string>
<string name="screen_create_room_room_visibility_section_title">"ルームの公開度"</string>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekreft at du vil deaktivere kontoen din. Denne handlingen kan ikke angres."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekreft at du vil slette kontoen din. Denne handlingen kan ikke angres."</string>
<string name="screen_deactivate_account_delete_all_messages">"Slett alle meldingene mine"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Advarsel: Fremtidige brukere vil kunne se ufullstendige samtaler."</string>
<string name="screen_deactivate_account_description">"Deaktivering av kontoen din er %1$s , det vil:"</string>
<string name="screen_deactivate_account_description">"Hvis %1$s sletter kontoen din, vil følgende skje:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversibel"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s kontoen din (du kan ikke logge på igjen, og ID-en din kan ikke brukes på nytt)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Deaktiver permanent"</string>
<string name="screen_deactivate_account_list_item_2">"Fjern deg fra alle chatterom."</string>
<string name="screen_deactivate_account_list_item_3">"Slett kontoinformasjonen din fra vår identitetsserver."</string>
<string name="screen_deactivate_account_list_item_4">"Meldingene dine vil fortsatt være synlige for registrerte brukere, men vil ikke være tilgjengelige for nye eller uregistrerte brukere hvis du velger å slette dem."</string>
<string name="screen_deactivate_account_title">"Deaktiver kontoen"</string>
<string name="screen_deactivate_account_title">"Slett kontoen"</string>
</resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Iltimos, hisobingizni o‘chirishni xohlayotganingizni tasdiqlang. Bu amalni qaytarib bo‘lmaydi."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Iltimos, hisobingizni oʻchirishni xohlayotganingizni tasdiqlang. Bu amalni qaytarib boʻlmaydi."</string>
<string name="screen_deactivate_account_delete_all_messages">"Barcha xabarlarimni o‘chirib tashlang"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Ogohlantirish: Kelgusi foydalanuvchilar chala suhbatlarni ko‘rishi mumkin."</string>
<string name="screen_deactivate_account_description">"Hisobingiz %1$s faolsizlantirilmoqda, u quyidagilarni bajaradi:"</string>
@@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"نمی‌توانید تأیید کنید؟"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"ایجاد کلید بازیابی جدید"</string>
<string name="screen_identity_confirmation_subtitle">"تأیید این افزاره برای برپایی پیام‌رسانی امن."</string>
<string name="screen_identity_confirmation_title">"تأیید هویتتان"</string>
<string name="screen_identity_confirmation_subtitle">"گزینش چگونگی تأیید برای برپایی پیام‌رسانی امن."</string>
<string name="screen_identity_confirmation_title">"تأیید هویت دیجیتالیتان"</string>
<string name="screen_identity_confirmation_use_another_device">"استفاده از افزاره‌ای دیگر"</string>
<string name="screen_identity_confirmation_use_recovery_key">"استفاده از کلید بازیابی"</string>
<string name="screen_identity_confirmed_subtitle">"اکنون می‌توانید پیام‌ها را به صورت امن فرستاده و بگیرید و هرکسی که با او گپ می‌زنید نیز می‌تواند به این افزاره اعتماد کند."</string>
@@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan du ikke bekrefte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opprett en ny gjenopprettingsnøkkel"</string>
<string name="screen_identity_confirmation_subtitle">"Verifiser denne enheten for å sette opp sikker meldingsutveksling."</string>
<string name="screen_identity_confirmation_subtitle">"Velg hvordan du vil verifisere deg for å sette opp sikker meldingsutveksling."</string>
<string name="screen_identity_confirmation_title">"Bekreft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Bruk en annen enhet"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Bruk gjenopprettingsnøkkel"</string>
@@ -3,6 +3,8 @@
<string name="banner_battery_optimization_content_android">"Nonaktifkan pengoptimalan baterai untuk aplikasi ini, untuk memastikan semua notifikasi diterima."</string>
<string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string>
<string name="banner_battery_optimization_title_android">"Notifikasi tidak masuk?"</string>
<string name="banner_new_sound_message">"Pemberitahuan Anda telah diperbarui—lebih jelas, lebih cepat, dan tidak mengganggu."</string>
<string name="banner_new_sound_title">"Kami telah memperbarui suara Anda."</string>
<string name="banner_set_up_recovery_content">"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."</string>
<string name="banner_set_up_recovery_submit">"Siapkan Kunci pemulihan"</string>
<string name="banner_set_up_recovery_title">"Cadangkan obrolan Anda"</string>
@@ -48,6 +50,7 @@ Anda tidak memiliki pesan yang belum dibaca!"</string>
<string name="screen_roomlist_mark_as_read">"Tandai sebagai dibaca"</string>
<string name="screen_roomlist_mark_as_unread">"Tandai sebagai belum dibaca"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ruangan ini telah ditingkatkan"</string>
<string name="screen_roomlist_your_spaces">"Ruang Anda"</string>
<string name="session_verification_banner_message">"Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya."</string>
<string name="session_verification_banner_title">"Verifikasi bahwa ini Anda"</string>
</resources>
@@ -5,9 +5,9 @@
<string name="banner_battery_optimization_title_android">"Kommer ikke varslene frem?"</string>
<string name="banner_new_sound_message">"Varslingssignalet ditt er oppdatert – tydeligere, raskere og mindre forstyrrende."</string>
<string name="banner_new_sound_title">"Vi har oppdatert lydene dine"</string>
<string name="banner_set_up_recovery_content">"Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter."</string>
<string name="banner_set_up_recovery_content">"Chattene dine sikkerhetskopieres automatisk med ende-til-ende-kryptering. For å gjenopprette denne sikkerhetskopien og beholde din digitale identitet når du mister tilgang til alle enhetene dine, trenger du gjenopprettingsnøkkelen din."</string>
<string name="banner_set_up_recovery_submit">"Hent gjenopprettingsnøkkel"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gjenoppretting for å beskytte kontoen din"</string>
<string name="banner_set_up_recovery_title">"Sikkerhetskopier chattene dine"</string>
<string name="confirm_recovery_key_banner_message">"Verifiser gjenopprettingsnøkkelen for å opprettholde tilgangen til nøkkellageret og meldingshistorikken."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Skriv inn gjenopprettingsnøkkelen din"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gjenopprettingsnøkkel?"</string>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Sudah menjadi anggota"</string>
<string name="screen_invite_users_already_invited">"Sudah diundang"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Saat ini Anda belum memiliki obrolan dengan kontak-kontak ini. Konfirmasikan undangan untuk mengundang mereka ke ruang obrolan ini sebelum melanjutkan."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Saat ini Anda belum memiliki obrolan apa pun dengan kontak ini. Konfirmasikan undangan ke ruang obrolan ini sebelum melanjutkan."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Ingin mengundang kontak baru ke ruang ini?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Ingin mengundang kontak baru ke ruang ini?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Allaqachon a\'zo"</string>
<string name="screen_invite_users_already_invited">"Allaqachon taklif qilingan"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Hozirda ushbu kontaktlar bilan hech qanday chatingiz yoʻq. Davom etishdan oldin ularni ushbu xonaga taklif qilishni tasdiqlang."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Hozirda bu kontakt bilan hech qanday chatingiz yoʻq. Davom etishdan oldin ularni ushbu xonaga taklif qilishni tasdiqlang."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Bu xonaga yangi kontaktlar taklif qilinsinmi?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Bu xonaga yangi kontakt taklif qilinsinmi?"</string>
</resources>
@@ -13,6 +13,8 @@
<string name="screen_link_new_device_mobile_step2">"گزینش %1$s"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزاره‌های موجودتان هنوز امنند و نیازی نیست نگرانشان باشید."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"اکنون چه؟"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"آزمودن ورود دوباره با کد پاس در صورت مشکل شبکه"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"آزمون شبکهٔ وای‌فایی دیگر یا استفاده از دادهٔ همراهتان به جای وای‌فای در صورت مواجه دوباره با مشکل"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"ورود دستی در صورت کار نکردنش"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"اتّصال ناامن"</string>
<string name="screen_qr_code_login_device_code_subtitle">"از شما خواسته خواهد شد که دو رقم نشان داده روی این افزاره را وارد کنید."</string>
@@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Pindai kode QR"</string>
<string name="screen_link_new_device_desktop_step1">"Buka %1$s di laptop atau komputer desktop."</string>
<string name="screen_link_new_device_desktop_step3">"Pindai kode QR dengan perangkat ini"</string>
<string name="screen_link_new_device_desktop_submit">"Siap untuk memindai"</string>
<string name="screen_link_new_device_desktop_title">"Buka %1$s di komputer desktop untuk mendapatkan kode QR."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Angkanya tidak cocok"</string>
<string name="screen_link_new_device_enter_number_notice">"Masukkan kode 2 digit"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ini akan memverifikasi bahwa koneksi ke perangkat Anda yang lain aman."</string>
<string name="screen_link_new_device_enter_number_title">"Masukkan nomor yang ditampilkan di perangkat Anda yang lain"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Penyedia akun Anda tidak mendukung %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s tidak didukung"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Penyedia akun Anda tidak mendukung masuk ke perangkat baru menggunakan kode QR."</string>
<string name="screen_link_new_device_error_not_supported_title">"Kode QR tidak didukung"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Proses masuk dibatalkan di perangkat lain."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Permintaan masuk dibatalkan"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Masa masuk kedaluwarsa. Silakan coba lagi."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Proses masuk tidak selesai tepat waktu"</string>
<string name="screen_link_new_device_mobile_step1">"Buka %1$s di perangkat lain."</string>
<string name="screen_link_new_device_mobile_step2">"Pilih %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"Masuk dengan kode QR"</string>
<string name="screen_link_new_device_mobile_step3">"Pindai kode QR yang ditampilkan di sini dengan perangkat lain."</string>
<string name="screen_link_new_device_mobile_title">"Buka %1$s di perangkat lain."</string>
<string name="screen_link_new_device_root_desktop_computer">"Komputer desktop"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Memuat kode QR…"</string>
<string name="screen_link_new_device_root_mobile_device">"Perangkat seluler"</string>
<string name="screen_link_new_device_root_title">"Jenis perangkat apa yang ingin Anda tautkan?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Silakan coba lagi dan pastikan Anda telah memasukkan kode 2 digit dengan benar. Jika angka-angka tersebut masih tidak cocok, hubungi penyedia akun Anda."</string>
<string name="screen_link_new_device_wrong_number_title">"Angkanya tidak cocok"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Apa sekarang?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"</string>
@@ -23,6 +40,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Permintaan masuk dibatalkan"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Proses masuk ditolak di perangkat lain."</string>
<string name="screen_qr_code_login_error_declined_title">"Proses masuk ditolak"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Anda tidak perlu melakukan hal lain."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Perangkat Anda yang lain sudah masuk"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Masa masuk kedaluwarsa. Silakan coba lagi."</string>
<string name="screen_qr_code_login_error_expired_title">"Proses masuk tidak selesai tepat waktu"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR.
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Vaša povijest lokacije uživo bit će pohranjena u sobi i vidljiva članovima nakon završetka sesije."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Odaberite koliko dugo želite dijeliti svoju lokaciju uživo."</string>
<string name="screen_share_location_live_location_missing_permissions">"Nemate dopuštenja za dijeljenje svoje lokacije uživo u ovoj sobi"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Riwayat lokasi langsung Anda akan disimpan di ruang obrolan dan dapat dilihat oleh anggota setelah sesi berakhir."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Pilih berapa lama Anda ingin membagikan lokasi langsung Anda."</string>
<string name="screen_share_location_live_location_missing_permissions">"Anda tidak memiliki izin untuk membagikan lokasi real-time Anda di ruang ini"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Velg hvor lenge du vil dele din sanntidslokasjon."</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Jonli joylashuv tarixingiz chat-xonada saqlanadi va sessiya tugaganidan keyin homiylarga ko‘rinadi."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Jonli joylashuvingiz qancha vaqt ulashilishini tanlang."</string>
<string name="screen_share_location_live_location_missing_permissions">"Bu xonada jonli joylashuvingizni ulashish uchun sizda ruxsat yoʻq"</string>
</resources>
@@ -15,15 +15,23 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -32,6 +40,8 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.pinDigitBg
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PinEntryTextField(
@@ -39,16 +49,25 @@ fun PinEntryTextField(
isSecured: Boolean,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
initialIsFocus: Boolean = false,
) {
var isFocused by remember { mutableStateOf(initialIsFocus) }
val filledCount = pinEntry.digits.count { it is PinDigit.Filled }
val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field)
val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount)
BasicTextField(
modifier = modifier,
modifier = modifier
.onFocusChanged { isFocused = it.isFocused }
.clearAndSetSemantics {
contentDescription = "$pinFieldLabel, $digitsEnteredLabel"
},
value = pinEntry.toText(),
onValueChange = {
onValueChange(it)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
decorationBox = {
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured, isFocused = isFocused)
}
)
}
@@ -58,13 +77,19 @@ fun PinEntryTextField(
private fun PinEntryRow(
pinEntry: PinEntry,
isSecured: Boolean,
isFocused: Boolean,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
for (digit in pinEntry.digits) {
PinDigitView(digit = digit, isSecured = isSecured)
val focusedIndex = pinEntry.digits.indexOfFirst { it is PinDigit.Empty }
pinEntry.digits.forEachIndexed { index, digit ->
PinDigitView(
digit = digit,
isSecured = isSecured,
isFocused = isFocused && index == focusedIndex
)
}
}
}
@@ -73,11 +98,16 @@ private fun PinEntryRow(
private fun PinDigitView(
digit: PinDigit,
isSecured: Boolean,
isFocused: Boolean,
) {
val shape = RoundedCornerShape(8.dp)
val appearanceModifier = when (digit) {
PinDigit.Empty -> {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
if (isFocused) {
Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape)
} else {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
}
}
is PinDigit.Filled -> {
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
@@ -108,18 +138,19 @@ private fun PinDigitView(
internal fun PinEntryTextFieldPreview() {
ElementPreview {
val pinEntry = PinEntry.createEmpty(4).fillWith("12")
Column {
PinEntryTextField(
pinEntry = pinEntry,
isSecured = true,
onValueChange = {},
)
Spacer(modifier = Modifier.size(16.dp))
PinEntryTextField(
pinEntry = pinEntry,
isSecured = false,
onValueChange = {},
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
listOf(true, false).forEach { isSecured ->
listOf(true, false).forEach { initialIsFocus ->
PinEntryTextField(
pinEntry = pinEntry,
isSecured = isSecured,
initialIsFocus = initialIsFocus,
onValueChange = {},
)
}
}
}
}
}
@@ -13,4 +13,6 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
sealed interface ChangeServerEvents {
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
data object ClearError : ChangeServerEvents
data object RequestLocalNetworkPermission : ChangeServerEvents
data object DismissLocalNetworkPermission : ChangeServerEvents
}
@@ -12,50 +12,55 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProvider
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.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
class ChangeServerPresenter(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
) : Presenter<ChangeServerState> {
@Composable
override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope()
val changeServerAction: MutableState<AsyncData<Unit>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
val gateState = localNetworkPermissionGate.present<AccountProvider>(
urlOf = { provider -> provider.url },
onProceed = { provider -> changeServer(provider, changeServerAction) },
)
fun handleEvent(event: ChangeServerEvents) {
when (event) {
is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction)
ChangeServerEvents.ClearError -> changeServerAction.value = AsyncData.Uninitialized
is ChangeServerEvents.ChangeServer -> gateState.submit(event.accountProvider)
ChangeServerEvents.DismissLocalNetworkPermission -> gateState.abort()
ChangeServerEvents.RequestLocalNetworkPermission -> gateState.requestPermission()
}
}
return ChangeServerState(
changeServerAction = changeServerAction.value,
localNetworkPermissionDialog = gateState.dialog,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.changeServer(
private suspend fun changeServer(
data: AccountProvider,
changeServerAction: MutableState<AsyncData<Unit>>,
) = launch {
) {
suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
title = data.title,
@@ -9,8 +9,10 @@
package io.element.android.features.login.impl.changeserver
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class ChangeServerState(
val changeServerAction: AsyncData<Unit>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit
)
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.changeserver
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
override val values: Sequence<ChangeServerState>
@@ -39,12 +40,15 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
ChangeServerError.UnsupportedServer
)
),
aChangeServerState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
)
}
fun aChangeServerState(
changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = ChangeServerState(
changeServerAction = changeServerAction,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = {}
)
@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -121,6 +122,15 @@ fun ChangeServerView(
}
AsyncData.Uninitialized -> Unit
}
LocalNetworkPermissionDialogView(
dialog = state.localNetworkPermissionDialog,
onSubmit = {
eventSink.invoke(ChangeServerEvents.RequestLocalNetworkPermission)
},
onDismiss = {
eventSink.invoke(ChangeServerEvents.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter
interface LoginModule {
@Binds
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
@Binds
fun bindLoginModePresenter(presenter: LoginModePresenter): Presenter<LoginModeState>
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.localnetwork
import android.Manifest
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.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import kotlinx.coroutines.launch
@Inject
class LocalNetworkPermissionGate(
private val advisor: LocalNetworkPermissionAdvisor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) {
private val permissionsPresenter: PermissionsPresenter =
permissionsPresenterFactory.create(Manifest.permission.ACCESS_LOCAL_NETWORK)
@Composable
fun <T : Any> present(
urlOf: (T) -> String,
onProceed: suspend (T) -> Unit,
): LocalNetworkPermissionGateState<T> {
val coroutineScope = rememberCoroutineScope()
val permissionsState = permissionsPresenter.present()
var pendingSubmit by remember { mutableStateOf<T?>(null) }
val latestUrlOf by rememberUpdatedState(urlOf)
val latestOnProceed by rememberUpdatedState(onProceed)
LaunchedEffect(permissionsState.permissionGranted, pendingSubmit) {
val pending = pendingSubmit
if (pending != null && permissionsState.permissionGranted) {
coroutineScope.launch { latestOnProceed(pending) }
pendingSubmit = null
}
}
val dialog by rememberUpdatedState(
when {
pendingSubmit == null -> LocalNetworkPermissionDialog.None
permissionsState.permissionGranted -> LocalNetworkPermissionDialog.None
permissionsState.shouldShowRationale || !permissionsState.permissionAlreadyAsked -> LocalNetworkPermissionDialog.Rationale
else -> LocalNetworkPermissionDialog.Settings
}
)
fun submit(value: T) {
coroutineScope.launch {
if (advisor.shouldRequestPermissionFor(latestUrlOf(value))) {
pendingSubmit = value
} else {
latestOnProceed(value)
}
}
}
fun requestPermission() {
when (dialog) {
LocalNetworkPermissionDialog.Settings -> {
permissionsState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
}
LocalNetworkPermissionDialog.Rationale -> {
permissionsState.eventSink(PermissionsEvent.ForceRequestPermissions)
}
else -> Unit
}
}
fun abort() {
pendingSubmit = null
}
return LocalNetworkPermissionGateState(
dialog = dialog,
submit = ::submit,
requestPermission = ::requestPermission,
abort = ::abort,
)
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.localnetwork
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class LocalNetworkPermissionGateState<T>(
val dialog: LocalNetworkPermissionDialog,
val submit: (T) -> Unit,
val requestPermission: () -> Unit,
val abort: () -> Unit,
)
@@ -1,128 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
/**
* This class is responsible for managing the login flow, including handling OIDC actions and
* submitting login requests.
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
* and [ChooseAccountProviderPresenter].
*/
@Inject
class LoginHelper(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) {
private val loginModeState: MutableState<AsyncData<LoginMode>> = mutableStateOf(AsyncData.Uninitialized)
@Composable
fun collectLoginMode(): State<AsyncData<LoginMode>> {
LaunchedEffect(Unit) {
oAuthActionFlow.collect { oAuthAction ->
if (oAuthAction != null) {
onOAuthAction(oAuthAction)
}
}
}
return loginModeState
}
fun clearError() {
loginModeState.value = AsyncData.Uninitialized
}
suspend fun submit(
isAccountCreation: Boolean,
homeserverUrl: String,
resolvedHomeserverUrl: String?,
loginHint: String?,
) {
suspend {
authenticationService.setHomeserver(homeserverUrl).recoverCatching {
// No .well-known file?
// If the homeserver is not reachable, try using resolvedHomeserverUrl.
if (resolvedHomeserverUrl != null && resolvedHomeserverUrl != homeserverUrl) {
authenticationService.setHomeserver(resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
}.map { matrixHomeServerDetails ->
if (matrixHomeServerDetails.supportsOAuthLogin) {
// Retrieve the details right now
val oAuthPrompt = if (isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = loginHint).getOrThrow()
)
} else if (isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginMode.AccountCreation(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginMode.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginModeState,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOAuthAction(oAuthAction: OAuthAction) {
if (oAuthAction is OAuthAction.GoBack && oAuthAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
// Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode.
// This can happen if there is an error, for instance attempt to login again on the same account.
return
}
loginModeState.value = AsyncData.Loading()
when (oAuthAction) {
is OAuthAction.GoBack -> {
authenticationService.cancelOAuthLogin()
.onSuccess {
loginModeState.value = AsyncData.Uninitialized
}
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
is OAuthAction.Success -> {
authenticationService.loginWithOAuth(oAuthAction.url)
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
}
oAuthActionFlow.reset()
}
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.login
sealed interface LoginModeEvent {
data class Submit(
val isAccountCreation: Boolean,
val homeserverUrl: String,
val resolvedHomeserverUrl: String?,
val loginHint: String?,
) : LoginModeEvent
data object ClearError : LoginModeEvent
data object RequestLocalNetworkPermission : LoginModeEvent
data object DismissLocalNetworkPermission : LoginModeEvent
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
import io.element.android.features.login.impl.screens.classic.loginwithclassic.LoginWithClassicPresenter
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
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
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
/**
* Presenter responsible for managing the login flow, including handling OAuth actions and
* submitting login requests.
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter],
* [ChooseAccountProviderPresenter] and [LoginWithClassicPresenter].
*/
@Inject
class LoginModePresenter(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
) : Presenter<LoginModeState> {
@Composable
override fun present(): LoginModeState {
val loginMode: MutableState<AsyncData<LoginMode>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val gateState = localNetworkPermissionGate.present<LoginModeEvent.Submit>(
urlOf = { it.homeserverUrl },
onProceed = { request -> performSubmit(request, loginMode) },
)
// Forward OAuth navigation events to the presenter's own loginMode state.
LaunchedEffect(Unit) {
oAuthActionFlow.collect { action ->
if (action != null) handleOAuthAction(action, loginMode)
}
}
fun handleEvent(event: LoginModeEvent) {
when (event) {
LoginModeEvent.ClearError -> loginMode.value = AsyncData.Uninitialized
is LoginModeEvent.Submit -> gateState.submit(event)
LoginModeEvent.DismissLocalNetworkPermission -> gateState.abort()
LoginModeEvent.RequestLocalNetworkPermission -> gateState.requestPermission()
}
}
return LoginModeState(
loginMode = loginMode.value,
localNetworkPermissionDialog = gateState.dialog,
eventSink = ::handleEvent,
)
}
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState<AsyncData<LoginMode>>) {
suspend {
authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
// Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
}.map { matrixHomeServerDetails ->
when {
matrixHomeServerDetails.supportsOAuthLogin -> {
val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
}
request.isAccountCreation -> LoginMode.AccountCreation(
webClientUrlForAuthenticationRetriever.retrieve(request.homeserverUrl)
)
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
else -> error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginMode,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun handleOAuthAction(action: OAuthAction, loginMode: MutableState<AsyncData<LoginMode>>) {
if (action is OAuthAction.GoBack && action.toUnblock && loginMode.value !is AsyncData.Loading) {
// Ignore GoBack that isn't tied to an in-flight login — it comes from LoginFlowNode
// when the user backs out after a completed login attempt.
return
}
loginMode.value = AsyncData.Loading()
when (action) {
is OAuthAction.GoBack -> authenticationService.cancelOAuthLogin()
.onSuccess { loginMode.value = AsyncData.Uninitialized }
.onFailure { loginMode.value = AsyncData.Failure(it) }
is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url)
.onFailure { loginMode.value = AsyncData.Failure(it) }
}
oAuthActionFlow.reset()
}
}
@@ -1,6 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@@ -8,7 +7,15 @@
package io.element.android.features.login.impl.login
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class LoginModeState(
val loginMode: AsyncData<LoginMode>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (LoginModeEvent) -> Unit,
)
sealed interface LoginMode {
data object PasswordLogin : LoginMode
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.login
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
fun aLoginModeState(
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
eventSink: (LoginModeEvent) -> Unit = {},
) = LoginModeState(
loginMode = loginMode,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = eventSink,
)
@@ -14,6 +14,7 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@@ -28,6 +29,7 @@ import java.util.Collections
class HomeserverResolver(
private val dispatchers: CoroutineDispatchers,
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
) {
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
@@ -39,13 +41,17 @@ class HomeserverResolver(
// Run all the requests in parallel
withContext(dispatchers.io) {
list.parallelMap { url ->
val isValid = homeServerLoginCompatibilityChecker.check(url)
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
.getOrNull()
?: return@parallelMap
// Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first —
// otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a
// candidate directly; the actual sign-in flow triggers the permission prompt.
val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) ||
homeServerLoginCompatibilityChecker.check(url)
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
.getOrNull()
?: return@parallelMap
// Emit the list as soon as possible
if (isValid) {
if (shouldRequestPermissionOrIsValid) {
currentList.add(HomeserverData(homeserverUrl = url))
withContext(flowContext) {
emit(currentList.toList())
@@ -12,50 +12,49 @@ import androidx.compose.runtime.Composable
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 dev.zacsweers.metro.Inject
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Inject
class ChooseAccountProviderPresenter(
private val enterpriseService: EnterpriseService,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
) : Presenter<ChooseAccountProviderState> {
@Composable
override fun present(): ChooseAccountProviderState {
val localCoroutineScope = rememberCoroutineScope()
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
var selectedAccountProvider: AccountProvider? by remember { mutableStateOf(null) }
fun handleEvent(event: ChooseAccountProviderEvents) {
when (event) {
ChooseAccountProviderEvents.Continue -> localCoroutineScope.launch {
selectedAccountProvider?.let {
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = it.url,
resolvedHomeserverUrl = null,
loginHint = null,
ChooseAccountProviderEvents.Continue -> {
selectedAccountProvider?.let { provider ->
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = provider.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
)
}
}
is ChooseAccountProviderEvents.SelectAccountProvider -> {
// Ensure that the user do not change the server during processing
if (loginMode is AsyncData.Uninitialized) {
// Ensure that the user does not change the server during processing.
if (loginModeState.loginMode is AsyncData.Uninitialized) {
selectedAccountProvider = event.accountProvider
}
}
ChooseAccountProviderEvents.ClearError -> loginHelper.clearError()
ChooseAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
}
}
@@ -77,7 +76,7 @@ class ChooseAccountProviderPresenter(
return ChooseAccountProviderState(
accountProviders = staticAccountProviderList,
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = ::handleEvent,
)
}
@@ -9,16 +9,17 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
data class ChooseAccountProviderState(
val accountProviders: ImmutableList<AccountProvider>,
val selectedAccountProvider: AccountProvider?,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (ChooseAccountProviderEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = selectedAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
get() = selectedAccountProvider != null &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
}
@@ -11,7 +11,8 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider
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.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.toImmutableList
@@ -58,7 +59,7 @@ open class ChooseAccountProviderStateProvider : PreviewParameterProvider<ChooseA
server3,
),
selectedAccountProvider = server2,
loginMode = AsyncData.Loading(),
loginModeState = aLoginModeState(loginMode = AsyncData.Loading()),
),
// Add other state here
)
@@ -69,11 +70,11 @@ fun aChooseAccountProviderState(
anAccountProvider()
),
selectedAccountProvider: AccountProvider? = null,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginModeState: LoginModeState = aLoginModeState(),
eventSink: (ChooseAccountProviderEvents) -> Unit = {},
) = ChooseAccountProviderState(
accountProviders = accountProviders.toImmutableList(),
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = eventSink,
)
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -44,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -56,9 +58,9 @@ fun ChooseAccountProviderView(
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
state.loginModeState.loginMode is AsyncData.Loading
}
}
@@ -124,7 +126,7 @@ fun ChooseAccountProviderView(
Spacer(modifier = Modifier.height(48.dp))
}
LoginModeView(
loginMode = state.loginMode,
loginMode = state.loginModeState.loginMode,
onClearError = {
state.eventSink(ChooseAccountProviderEvents.ClearError)
},
@@ -135,6 +137,15 @@ fun ChooseAccountProviderView(
)
}
}
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
},
onDismiss = {
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -20,7 +20,8 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -32,7 +33,7 @@ import kotlinx.coroutines.launch
class LoginWithClassicPresenter(
@Assisted private val userId: UserId,
@Assisted private val navigator: LoginWithClassicNavigator,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
private val elementClassicConnection: ElementClassicConnection,
private val accountProviderDataSource: AccountProviderDataSource,
private val buildMeta: BuildMeta,
@@ -51,7 +52,7 @@ class LoginWithClassicPresenter(
var loginWithClassicAction by remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState()
fun handleEvent(event: LoginWithClassicEvent) {
@@ -69,11 +70,13 @@ class LoginWithClassicPresenter(
val elementClassicUserId = currentState.elementClassicSession.userId
val accountProvider = elementClassicUserId.domainName.orEmpty().ensureProtocol()
accountProviderDataSource.setUrl(accountProvider)
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = accountProvider,
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
loginHint = "mxid:" + elementClassicUserId.value,
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = accountProvider,
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
loginHint = "mxid:" + elementClassicUserId.value,
)
)
}
}
@@ -83,7 +86,7 @@ class LoginWithClassicPresenter(
}
LoginWithClassicEvent.ClearError -> {
loginWithClassicAction = AsyncAction.Uninitialized
loginHelper.clearError()
loginModeState.eventSink(LoginModeEvent.ClearError)
}
}
}
@@ -94,7 +97,7 @@ class LoginWithClassicPresenter(
userId = userId,
displayName = elementClassicReady?.displayName,
avatar = elementClassicReady?.avatar,
loginMode = loginMode,
loginModeState = loginModeState,
loginWithClassicAction = loginWithClassicAction,
eventSink = ::handleEvent,
)
@@ -9,9 +9,8 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic
import android.graphics.Bitmap
import androidx.compose.runtime.Stable
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
@Stable
@@ -21,6 +20,6 @@ data class LoginWithClassicState(
val displayName: String?,
val avatar: Bitmap?,
val loginWithClassicAction: AsyncAction<Unit>,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (LoginWithClassicEvent) -> Unit,
)
@@ -9,9 +9,9 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic
import android.graphics.Bitmap
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
@@ -29,7 +29,7 @@ fun aLoginWithClassicState(
displayName: String? = null,
avatar: Bitmap? = null,
loginWithClassicAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginModeState: LoginModeState = aLoginModeState(),
eventSink: (LoginWithClassicEvent) -> Unit = {},
) = LoginWithClassicState(
isElementPro = isElementPro,
@@ -37,6 +37,6 @@ fun aLoginWithClassicState(
displayName = displayName,
avatar = avatar,
loginWithClassicAction = loginWithClassicAction,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = eventSink,
)
@@ -35,6 +35,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@@ -50,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -65,9 +67,9 @@ fun LoginWithClassicView(
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
state.loginModeState.loginMode is AsyncData.Loading
}
}
@@ -195,7 +197,7 @@ fun LoginWithClassicView(
}
)
LoginModeView(
loginMode = state.loginMode,
loginMode = state.loginModeState.loginMode,
onClearError = {
state.eventSink(LoginWithClassicEvent.ClearError)
},
@@ -204,6 +206,15 @@ fun LoginWithClassicView(
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
},
onDismiss = {
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -11,20 +11,19 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
@AssistedInject
class ConfirmAccountProviderPresenter(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
@@ -38,28 +37,26 @@ class ConfirmAccountProviderPresenter(
@Composable
override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
fun handleEvent(event: ConfirmAccountProviderEvents) {
when (event) {
ConfirmAccountProviderEvents.Continue -> localCoroutineScope.launch {
loginHelper.submit(
ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
}
ConfirmAccountProviderEvents.ClearError -> loginHelper.clearError()
)
ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
}
}
return ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = params.isAccountCreation,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = ::handleEvent,
)
}
@@ -9,14 +9,16 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
data class ConfirmAccountProviderState(
val accountProvider: AccountProvider,
val isAccountCreation: Boolean,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
val submitEnabled: Boolean
get() = accountProvider.url.isNotEmpty() &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
}
@@ -11,7 +11,8 @@ 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.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
@@ -24,7 +25,7 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
),
aConfirmAccountProviderState(
isAccountCreation = true,
loginMode = AsyncData.Failure(AccountCreationNotSupported())
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AccountCreationNotSupported())),
),
)
}
@@ -32,11 +33,11 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(),
isAccountCreation: Boolean = false,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginModeState: LoginModeState = aLoginModeState(),
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = isAccountCreation,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = eventSink
)
@@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@@ -31,6 +32,7 @@ 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.TextButton
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -45,9 +47,9 @@ fun ConfirmAccountProviderView(
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
state.loginModeState.loginMode is AsyncData.Loading
}
}
val eventSink = state.eventSink
@@ -98,7 +100,7 @@ fun ConfirmAccountProviderView(
}
) {
LoginModeView(
loginMode = state.loginMode,
loginMode = state.loginModeState.loginMode,
onClearError = {
eventSink(ConfirmAccountProviderEvents.ClearError)
},
@@ -108,6 +110,16 @@ fun ConfirmAccountProviderView(
onCreateAccountContinue = onCreateAccountContinue,
)
}
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
},
onDismiss = {
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -25,7 +25,8 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -41,7 +42,7 @@ class OnBoardingPresenter(
private val enterpriseService: EnterpriseService,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
private val sessionStore: SessionStore,
private val accountProviderDataSource: AccountProviderDataSource,
@@ -98,21 +99,23 @@ class OnBoardingPresenter(
value = sessionStore.numberOfSessions() > 0
}
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
fun handleEvent(event: OnBoardingEvents) {
when (event) {
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
// Ensure that the current account provider is set
accountProviderDataSource.setUrl(event.defaultAccountProvider)
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
)
)
}
OnBoardingEvents.ClearError -> loginHelper.clearError()
OnBoardingEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
OnBoardingEvents.OnVersionClick -> {
if (canReportBug) {
if (multipleTapToUnlock.unlock(localCoroutineScope)) {
@@ -133,7 +136,7 @@ class OnBoardingPresenter(
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug && showReportBug,
loginMode = loginMode,
loginModeState = loginModeState,
version = buildMeta.versionName,
onBoardingLogoResId = onBoardingLogoResId,
eventSink = ::handleEvent,
@@ -9,7 +9,7 @@
package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
@@ -25,9 +25,10 @@ data class OnBoardingState(
val version: String,
@DrawableRes
val onBoardingLogoResId: Int?,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = defaultAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
get() = defaultAccountProvider != null &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
}
@@ -10,8 +10,8 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.designsystem.R
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
@@ -49,7 +49,7 @@ fun anOnBoardingState(
version: String = "1.0.0",
@DrawableRes
customLogoResId: Int? = null,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginModeState: LoginModeState = aLoginModeState(),
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
isAddingAccount = isAddingAccount,
@@ -62,7 +62,7 @@ fun anOnBoardingState(
canCreateAccount = canCreateAccount,
canReportBug = canReportBug,
version = version,
loginMode = loginMode,
loginModeState = loginModeState,
onBoardingLogoResId = customLogoResId,
eventSink = eventSink,
)
@@ -33,6 +33,7 @@ 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.login.impl.R
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
@@ -50,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -74,7 +76,7 @@ fun OnBoardingView(
) {
val loginView = @Composable {
LoginModeView(
loginMode = state.loginMode,
loginMode = state.loginModeState.loginMode,
onClearError = {
state.eventSink(OnBoardingEvents.ClearError)
},
@@ -83,6 +85,15 @@ fun OnBoardingView(
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
},
onDismiss = {
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
}
)
}
val buttons = @Composable {
OnBoardingButtons(
@@ -260,9 +271,9 @@ private fun OnBoardingButtons(
onCreateAccount: () -> Unit,
onReportProblem: () -> Unit,
) {
val isLoading by remember(state.loginMode) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
state.loginModeState.loginMode is AsyncData.Loading
}
}
@@ -41,6 +41,8 @@
<string name="screen_qr_code_login_connecting_subtitle">"برقرار کدن اتّصالی امن"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزاره‌های موجودتان هنوز امنند و نیازی نیست نگرانشان باشید."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"اکنون چه؟"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"آزمودن ورود دوباره با کد پاس در صورت مشکل شبکه"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"آزمون شبکهٔ وای‌فایی دیگر یا استفاده از دادهٔ همراهتان به جای وای‌فای در صورت مواجه دوباره با مشکل"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"ورود دستی در صورت کار نکردنش"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"اتّصال ناامن"</string>
<string name="screen_qr_code_login_device_code_subtitle">"از شما خواسته خواهد شد که دو رقم نشان داده روی این افزاره را وارد کنید."</string>
@@ -37,11 +37,20 @@
<string name="screen_login_subtitle">"Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi."</string>
<string name="screen_login_title">"Selamat datang kembali!"</string>
<string name="screen_login_title_with_homeserver">"Masuk ke %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Buka Element Classic"</string>
<string name="screen_missing_key_backup_step_1">"Buka Element Classic di perangkat Anda"</string>
<string name="screen_missing_key_backup_step_2_android">"Buka Pengaturan &gt; Keamanan &amp;amp; Privasi"</string>
<string name="screen_missing_key_backup_step_3_android">"Dalam pengelolaan kunci kriptografi, pilih Pemulihan pesan terenkripsi"</string>
<string name="screen_missing_key_backup_step_4">"Ikuti petunjuk berikut untuk mengaktifkan penyimpanan kunci Anda"</string>
<string name="screen_missing_key_backup_step_5">"Kembali ke %1$s"</string>
<string name="screen_missing_key_backup_title">"Aktifkan penyimpanan kunci Anda sebelum melanjutkan ke %1$s"</string>
<string name="screen_onboarding_app_version">"Versi %1$s"</string>
<string name="screen_onboarding_checking_account">"Memeriksa akun…"</string>
<string name="screen_onboarding_sign_in_manually">"Masuk secara manual"</string>
<string name="screen_onboarding_sign_in_to">"Masuk ke %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Masuk dengan kode QR"</string>
<string name="screen_onboarding_sign_up">"Buat akun"</string>
<string name="screen_onboarding_welcome_back">"Selamat datang kembali!"</string>
<string name="screen_onboarding_welcome_message">"Selamat datang di %1$s tercepat yang pernah ada. Berdaya besar untuk kecepatan dan kesederhanaan."</string>
<string name="screen_onboarding_welcome_subtitle">"Selamat datang di %1$s. Berdaya penuh, untuk kecepatan dan kesederhanaan."</string>
<string name="screen_onboarding_welcome_title">"Berada di elemen Anda"</string>
@@ -60,6 +69,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Permintaan masuk dibatalkan"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Proses masuk ditolak di perangkat lain."</string>
<string name="screen_qr_code_login_error_declined_title">"Proses masuk ditolak"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Anda tidak perlu melakukan hal lain."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Perangkat Anda yang lain sudah masuk"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Masa masuk kedaluwarsa. Silakan coba lagi."</string>
<string name="screen_qr_code_login_error_expired_title">"Proses masuk tidak selesai tepat waktu"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR.
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Hva er adressen til serveren din?"</string>
<string name="screen_change_server_title">"Velg din server"</string>
<string name="screen_create_account_title">"Opprett konto"</string>
<string name="screen_login_error_deactivated_account">"Denne kontoen er deaktivert."</string>
<string name="screen_login_error_deactivated_account">"Denne kontoen er slettet."</string>
<string name="screen_login_error_invalid_credentials">"Feil brukernavn og/eller passord"</string>
<string name="screen_login_error_invalid_user_id">"Dette er ikke en gyldig brukeridentifikator. Forventet format: \'@bruker:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Denne serveren er konfigurert til å bruke oppdateringstokener. Disse støttes ikke når du bruker passordbasert pålogging."</string>
@@ -27,7 +27,7 @@
<string name="screen_change_server_subtitle">"Địa chỉ máy chủ của bạn là gì?"</string>
<string name="screen_change_server_title">"Chọn máy chủ của bạn"</string>
<string name="screen_create_account_title">"Tạo tài khoản"</string>
<string name="screen_login_error_deactivated_account">"Tài khoản này đã bị vô hiệu hóa."</string>
<string name="screen_login_error_deactivated_account">"Tài khoản này đã bị xóa."</string>
<string name="screen_login_error_invalid_credentials">"Tên người dùng và/hoặc mật khẩu không chính xác"</string>
<string name="screen_login_error_invalid_user_id">"Đây không phải là mã nhận dạng người dùng hợp lệ. Định dạng mong đợi: ‘@user:homeserver.org’"</string>
<string name="screen_login_error_refresh_tokens">"Máy chủ này được cấu hình sử dụng refresh token. Điều này không được hỗ trợ khi đăng nhập bằng mật khẩu."</string>
@@ -15,6 +15,7 @@ import io.element.android.features.login.impl.accesscontrol.DefaultAccountProvid
import io.element.android.features.login.impl.accountprovider.AccountProvider
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.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData
@@ -23,6 +24,10 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@@ -43,6 +48,7 @@ class ChangeServerPresenterTest {
createPresenter().test {
val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None)
}
}
@@ -183,11 +189,81 @@ class ChangeServerPresenterTest {
}
}
@Test
fun `present - advisor advises prompt, dialog shown before setHomeserver runs`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
)
createPresenter(
authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = FakePermissionsPresenter(),
).test {
val initialState = awaitItem()
assertThat(initialState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
val promptState = expectMostRecentItem()
// Dialog is shown, permission has not been requested yet, setHomeserver has not been called.
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
assertThat(promptState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
}
}
@Test
fun `present - dismissing the dialog aborts the submit`() = runTest {
createPresenter(
authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
),
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = FakePermissionsPresenter(),
).test {
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
val promptState = expectMostRecentItem()
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
promptState.eventSink.invoke(ChangeServerEvents.DismissLocalNetworkPermission)
val dismissedState = expectMostRecentItem()
assertThat(dismissedState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None)
assertThat(dismissedState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
}
}
@Test
fun `present - advisor advises prompt, permission granted resumes setHomeserver`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
)
val permissionsPresenter = FakePermissionsPresenter()
createPresenter(
authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = permissionsPresenter,
).test {
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
val promptState = expectMostRecentItem()
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
permissionsPresenter.setPermissionGranted()
// Await recompositions until the deferred setHomeserver completes.
var finalState = awaitItem()
while (finalState.changeServerAction !is AsyncData.Success) {
finalState = awaitItem()
}
assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
}
}
private fun createPresenter(
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(),
) = ChangeServerPresenter(
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
@@ -195,5 +271,9 @@ class ChangeServerPresenterTest {
enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever,
),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
),
)
}
@@ -12,8 +12,8 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
@@ -81,7 +81,7 @@ class ChooseAccountProviderPresenterTest {
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
)
@@ -105,7 +105,7 @@ class ChooseAccountProviderPresenterTest {
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
)
@@ -121,12 +121,12 @@ class ChooseAccountProviderPresenterTest {
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(ChooseAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
}
@@ -138,7 +138,7 @@ class ChooseAccountProviderPresenterTest {
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
)
@@ -152,7 +152,7 @@ class ChooseAccountProviderPresenterTest {
it.eventSink(ChooseAccountProviderEvents.Continue)
}
awaitItem().also {
assertThat(it.loginMode.isLoading()).isTrue()
assertThat(it.loginModeState.loginMode.isLoading()).isTrue()
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider2))
}
expectNoEvents()
@@ -162,8 +162,8 @@ class ChooseAccountProviderPresenterTest {
private fun createPresenter(
enterpriseService: EnterpriseService = FakeEnterpriseService(),
loginHelper: LoginHelper = createLoginHelper(),
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
) = ChooseAccountProviderPresenter(
enterpriseService = enterpriseService,
loginHelper = loginHelper,
loginModePresenter = loginModePresenter,
)
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData
import org.junit.Test
@@ -35,7 +36,7 @@ class ChooseAccountProviderStateTest {
fun `submitEnabled returns false when there is a selectedAccountProvider but there is an error`() {
val sut = aChooseAccountProviderState(
selectedAccountProvider = anAccountProvider(),
loginMode = AsyncData.Failure(Throwable("Error")),
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(Throwable("Error"))),
)
assertThat(sut.submitEnabled).isFalse()
}
@@ -44,7 +45,7 @@ class ChooseAccountProviderStateTest {
fun `submitEnabled returns false when there is a selectedAccountProvider but the result is successful`() {
val sut = aChooseAccountProviderState(
selectedAccountProvider = anAccountProvider(),
loginMode = AsyncData.Success(LoginMode.PasswordLogin),
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.PasswordLogin)),
)
assertThat(sut.submitEnabled).isFalse()
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -69,7 +70,7 @@ class ChooseAccountProviderViewTest : RobolectricTest() {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
setChooseAccountProviderView(
state = aChooseAccountProviderState(
loginMode = AsyncData.Failure(AN_EXCEPTION),
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AN_EXCEPTION)),
eventSink = eventSink,
),
)
@@ -16,8 +16,8 @@ import io.element.android.features.login.impl.classic.FakeElementClassicConnecti
import io.element.android.features.login.impl.classic.ROOM_KEYS_VERSION
import io.element.android.features.login.impl.classic.anElementClassicReady
import io.element.android.features.login.impl.classic.anElementClassicSession
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
@@ -42,7 +42,7 @@ class LoginWithClassicPresenterTest {
assertThat(initialState.displayName).isNull()
assertThat(initialState.avatar).isNull()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
assertThat(initialState.loginMode.isUninitialized()).isTrue()
assertThat(initialState.loginModeState.loginMode.isUninitialized()).isTrue()
}
}
@@ -69,7 +69,7 @@ class LoginWithClassicPresenterTest {
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
)
@@ -108,7 +108,7 @@ class LoginWithClassicPresenterTest {
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
)
@@ -147,7 +147,7 @@ class LoginWithClassicPresenterTest {
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
navigator = FakeLoginWithClassicNavigator(
@@ -188,7 +188,7 @@ class LoginWithClassicPresenterTest {
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
navigator = FakeLoginWithClassicNavigator(
@@ -247,14 +247,14 @@ class LoginWithClassicPresenterTest {
private fun createPresenter(
userId: UserId = A_USER_ID,
navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(),
loginHelper: LoginHelper = createLoginHelper(),
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
isEnterpriseBuild: Boolean = false,
) = LoginWithClassicPresenter(
userId = userId,
navigator = navigator,
loginHelper = loginHelper,
loginModePresenter = loginModePresenter,
elementClassicConnection = elementClassicConnection,
accountProviderDataSource = accountProviderDataSource,
buildMeta = aBuildMeta(
@@ -14,7 +14,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
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
@@ -43,7 +43,7 @@ class ConfirmAccountProviderPresenterTest {
assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue()
assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
assertThat(initialState.loginMode).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -62,11 +62,11 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
}
}
@@ -85,11 +85,11 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
}
}
@@ -110,15 +110,15 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenOAuthCancelError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
}
}
@@ -139,14 +139,14 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFinalState = awaitItem()
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
}
}
@@ -167,10 +167,10 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
val cancelFinalState = awaitItem()
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
}
}
@@ -191,17 +191,17 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.Success("aUrl"))
val cancelLoadingState = awaitItem()
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
}
}
@@ -222,14 +222,14 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
val successSuccessState = awaitItem()
assertThat(successSuccessState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
}
}
@@ -249,7 +249,7 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading
val failureState = awaitItem()
assertThat(failureState.submitEnabled).isFalse()
assertThat(failureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
}
}
@@ -273,12 +273,12 @@ class ConfirmAccountProviderPresenterTest {
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -302,11 +302,11 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -326,8 +326,8 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
}
}
@@ -349,8 +349,8 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
}
}
@@ -372,7 +372,7 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
}
}
@@ -385,7 +385,7 @@ class ConfirmAccountProviderPresenterTest {
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = matrixAuthenticationService,
oAuthActionFlow = defaultOAuthActionFlow,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
@@ -15,7 +15,8 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever
@@ -33,6 +34,7 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -255,7 +257,7 @@ class OnBoardingPresenterTest {
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
),
loginHelper = createLoginHelper(
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
),
accountProviderDataSource = accountProviderDataSource,
@@ -271,12 +273,12 @@ class OnBoardingPresenterTest {
assertThat(accountProviderDataSource.flow.first().url).isEqualTo(A_HOMESERVER_URL_2)
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(OnBoardingEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
}
@@ -292,7 +294,7 @@ private fun createPresenter(
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
rageshakeFeatureAvailability: () -> Flow<Boolean> = { flowOf(true) },
loginHelper: LoginHelper = createLoginHelper(),
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
sessionStore: SessionStore = InMemorySessionStore(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
@@ -305,18 +307,26 @@ private fun createPresenter(
wellknownRetriever = wellknownRetriever,
),
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginHelper = loginHelper,
loginModePresenter = loginModePresenter,
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
sessionStore = sessionStore,
accountProviderDataSource = accountProviderDataSource,
)
fun createLoginHelper(
fun createLoginModePresenter(
oAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
): LoginHelper = LoginHelper(
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory =
io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(),
): LoginModePresenter = LoginModePresenter(
oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
permissionsPresenterFactory = permissionsPresenterFactory,
),
)
@@ -21,6 +21,7 @@ import com.google.testing.junit.testparameterinjector.KotlinTestParameters.named
import com.google.testing.junit.testparameterinjector.TestParameter
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -148,7 +149,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
setOnboardingView(
state = anOnBoardingState(
defaultAccountProvider = "element.io",
loginMode = AsyncData.Failure(AN_EXCEPTION),
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AN_EXCEPTION)),
eventSink = eventSink,
),
)
@@ -208,7 +209,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
ensureCalledOnce { callback ->
setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.PasswordLogin),
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.PasswordLogin)),
eventSink = eventSink,
),
onNeedLoginPassword = callback,
@@ -224,7 +225,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
ensureCalledOnceWithParam(oAuthDetails) { callback ->
setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.OAuth(oAuthDetails)),
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.OAuth(oAuthDetails))),
eventSink = eventSink,
),
onOAuthDetails = callback,
@@ -240,7 +241,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
ensureCalledOnceWithParam(oAuthDetails.url) { callback ->
setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")),
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl"))),
eventSink = eventSink,
),
onCreateAccountContinue = callback,
@@ -13,6 +13,7 @@ import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverResolver
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -30,7 +31,7 @@ class SearchAccountProviderPresenterTest {
fun `present - initial state`() = runTest {
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) })
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
@@ -44,7 +45,7 @@ class SearchAccountProviderPresenterTest {
fun `present - error while checking login compatibility`() = runTest {
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) })
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
@@ -68,7 +69,7 @@ class SearchAccountProviderPresenterTest {
fun `present - enter text no result`() = runTest {
val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) })
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
@@ -101,7 +102,7 @@ class SearchAccountProviderPresenterTest {
}
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
@@ -141,7 +142,7 @@ class SearchAccountProviderPresenterTest {
}
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
@@ -4,15 +4,15 @@
<string name="screen_signout_confirmation_dialog_submit">"Fjern denne enheten"</string>
<string name="screen_signout_confirmation_dialog_title">"Fjern denne enheten"</string>
<string name="screen_signout_in_progress_dialog_content">"Fjerner enheten …"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Du er i ferd med å logge av din siste sesjon. Hvis du logger av nå, mister du tilgangen til de krypterte meldingene dine."</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Dette er din eneste enhet. Hvis du fjerner den, trenger du en gjenopprettingsnøkkel for å bekrefte din digitale identitet og gjenopprette de krypterte chattene dine neste gang du logger på."</string>
<string name="screen_signout_key_backup_disabled_title">"Du er i ferd med å miste tilgangen til dine krypterte chatter"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Nøklene dine ble fortsatt sikkerhetskopiert da du koblet fra. Koble til igjen, slik at nøklene dine kan sikkerhetskopieres før du logger av."</string>
<string name="screen_signout_key_backup_offline_title">"Nøklene dine blir fortsatt sikkerhetskopiert"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Vennligst vent til dette er fullført før du fjerner denne enheten."</string>
<string name="screen_signout_key_backup_ongoing_title">"Nøklene dine blir fortsatt sikkerhetskopiert"</string>
<string name="screen_signout_preference_item">"Fjern denne enheten"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Du er i ferd med å logge ut av din siste sesjon. Hvis du logger ut nå, mister du tilgangen til de krypterte meldingene dine."</string>
<string name="screen_signout_recovery_disabled_subtitle">"Dette er din eneste enhet. Hvis du fjerner den, trenger du en gjenopprettingsnøkkel for å bekrefte din digitale identitet og gjenopprette de krypterte chattene dine neste gang du logger på."</string>
<string name="screen_signout_recovery_disabled_title">"Du er i ferd med å miste tilgangen til dine krypterte chatter"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Du er i ferd med å logge av din siste sesjon. Hvis du logger av nå, kan du miste tilgangen til de krypterte meldingene dine."</string>
<string name="screen_signout_save_recovery_key_subtitle">"Dette er din eneste enhet. Hvis du fjerner den, trenger du en gjenopprettingsnøkkel for å bekrefte din digitale identitet og gjenopprette de krypterte chattene dine neste gang du logger på."</string>
<string name="screen_signout_save_recovery_key_title">"Sørg for at du har tilgang til gjenopprettingsnøkkelen din før du fjerner denne enheten"</string>
</resources>
@@ -16,6 +16,18 @@
<string name="emoji_picker_category_places">"Putovanja i mjesta"</string>
<string name="emoji_picker_category_recent">"Nedavni emotikoni"</string>
<string name="emoji_picker_category_symbols">"Simboli"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Okreni sliku vodoravno"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Okrenuto vodoravno"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Izvornik"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Okreni sliku okomito"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Okrenuto okomito"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Izvornik"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Okrenite sliku ulijevo"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d stupanj"</item>
<item quantity="other">"%1$d stupnjevi"</item>
</plurals>
<string name="screen_image_edition_title">"Uredi fotografiju"</string>
<string name="screen_media_upload_preview_caption_warning">"Opisi možda neće biti vidljivi osobama koji se služe starijim aplikacijama."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Dodirnite za promjenu kvalitete prijenosa videozapisa"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Datoteka se nije mogla prenijeti."</string>
@@ -26,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Stavka %1$d od %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiziraj kvalitetu slike"</string>
<string name="screen_media_upload_preview_processing">"Obrada…"</string>
<string name="screen_media_upload_preview_title">"Dodaj medij"</string>
<string name="screen_report_content_block_user">"Blokiraj korisnika"</string>
<string name="screen_report_content_block_user_hint">"Označite ako želite sakriti sve trenutačne i buduće poruke od ovog korisnika"</string>
<string name="screen_report_content_explanation">"Ova poruka bit će prijavljena administratoru vašeg matičnog poslužitelja. On neće moći pročitati nijednu šifriranu poruku."</string>
@@ -16,6 +16,11 @@
<string name="emoji_picker_category_places">"Wisata &amp; Tempat"</string>
<string name="emoji_picker_category_recent">"Emojis Sebelumnya"</string>
<string name="emoji_picker_category_symbols">"Simbol"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Putar gambar ke kiri"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d derajat"</item>
</plurals>
<string name="screen_image_edition_title">"Sunting Foto"</string>
<string name="screen_media_upload_preview_caption_warning">"Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Ketuk untuk mengubah kualitas unggahan video"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Dokumen tidak dapat diunggah."</string>
@@ -26,6 +31,7 @@
<string name="screen_media_upload_preview_item_count">"Item %1$d dari %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimalkan kualitas gambar"</string>
<string name="screen_media_upload_preview_processing">"Memproses…"</string>
<string name="screen_media_upload_preview_title">"Tambahkan media"</string>
<string name="screen_report_content_block_user">"Blokir pengguna"</string>
<string name="screen_report_content_block_user_hint">"Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini"</string>
<string name="screen_report_content_explanation">"Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun."</string>
@@ -16,6 +16,18 @@
<string name="emoji_picker_category_places">"Viaggi &amp; Luoghi"</string>
<string name="emoji_picker_category_recent">"Emoji recenti"</string>
<string name="emoji_picker_category_symbols">"Simboli"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Capovolgi l\'immagine orizzontalmente"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Capovolto orizzontalmente"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Capovolgi l\'immagine verticalmente"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Capovolto verticalmente"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Ruota l\'immagine verso sinistra"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d grado"</item>
<item quantity="other">"%1$d gradi"</item>
</plurals>
<string name="screen_image_edition_title">"Modifica foto"</string>
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tocca per modificare la qualità di caricamento del video"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Impossibile caricare il file."</string>
@@ -26,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Elemento %1$d di %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Ottimizza la qualità delle immagini"</string>
<string name="screen_media_upload_preview_processing">"Elaborazione…"</string>
<string name="screen_media_upload_preview_title">"Aggiungi contenuti multimediali"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>
<string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string>
<string name="screen_report_content_explanation">"Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi cifrati."</string>
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"旅・場所"</string>
<string name="emoji_picker_category_recent">"最近使用"</string>
<string name="emoji_picker_category_symbols">"記号"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平に反転"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"垂直に反転"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"元画像"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直に反転"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"水平に反転"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"編集前"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"画像を左に回転"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d°"</item>
@@ -16,6 +16,18 @@
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
<string name="emoji_picker_category_recent">"Oxirgi kulgichlar"</string>
<string name="emoji_picker_category_symbols">"Belgilar"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Rasmni gorizontal ravishda aylantiring"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Gorizontal ravishda aylantirildi"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Asl nusxasi"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Rasmni vertikal ravishda aylantirish"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Vertikal ravishda aylantirildi"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Asl nusxasi"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rasmni chapga aylantiring"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d daraja"</item>
<item quantity="other">"%1$d daraja"</item>
</plurals>
<string name="screen_image_edition_title">"Suratni tahrirlash"</string>
<string name="screen_media_upload_preview_caption_warning">"Taglavhalar eski ilovalardan foydalanuvchilarga ko‘rinmasligi mumkin."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Video yuklash sifatini oʻzgartirish uchun bosing"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Faylni yuklab boʻlmadi."</string>
@@ -26,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Element%1$d ning %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Tasvir sifatini optimallashtirish"</string>
<string name="screen_media_upload_preview_processing">"Qayta ishlanmoqda…"</string>
<string name="screen_media_upload_preview_title">"Media qoʻshish"</string>
<string name="screen_report_content_block_user">"Foydalanuvchini bloklash"</string>
<string name="screen_report_content_block_user_hint">"Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring"</string>
<string name="screen_report_content_explanation">"Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi."</string>
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
@@ -57,6 +58,7 @@ class PreferencesRootPresenter(
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
private val sessionEnterpriseService: SessionEnterpriseService,
private val userStatusPresenter: Presenter<UserStatusState>,
) : Presenter<PreferencesRootState> {
@Composable
override fun present(): PreferencesRootState {
@@ -66,13 +68,14 @@ class PreferencesRootPresenter(
// Force a refresh of the profile
matrixClient.getUserProfile()
}
val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false)
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -137,6 +140,7 @@ class PreferencesRootPresenter(
return PreferencesRootState(
myUser = matrixUser.value,
userStatusState = userStatusState,
version = remember { versionFormatter.get() },
deviceId = matrixClient.deviceId,
isMultiAccountEnabled = isMultiAccountEnabled,
@@ -9,6 +9,7 @@
package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -16,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList
data class PreferencesRootState(
val myUser: MatrixUser,
val userStatusState: UserStatusState?,
val version: String,
val deviceId: DeviceId?,
val isMultiAccountEnabled: Boolean,
@@ -11,6 +11,9 @@ package io.element.android.features.preferences.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -64,8 +67,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRo
showDeveloperSettings = true,
canDeactivateAccount = true,
),
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
// Minimal state
aPreferencesRootState(),
aPreferencesRootState(userStatusState = null),
)
}
@@ -86,6 +90,7 @@ fun aPreferencesRootState(
nbOfBlockedUsers: Int = 0,
showLabsItem: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
userStatusState: UserStatusState? = aUserStatusState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
) = PreferencesRootState(
@@ -105,6 +110,7 @@ fun aPreferencesRootState(
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
userStatusState = userStatusState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -23,6 +24,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent
@@ -85,10 +88,10 @@ fun PreferencesRootView(
state = state,
onAddAccountClick = onAddAccountClick,
)
} else {
HorizontalDivider()
}
// User status will be added here
if (state.userStatusState != null) {
UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled)
}
// 'Account' section
ManageAccountSection(
state = state,
@@ -129,6 +132,27 @@ fun PreferencesRootView(
}
}
@Composable
private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState,
showTopDivider: Boolean,
) {
if (showTopDivider) {
HorizontalDivider(
thickness = 8.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
UserStatusView(
state = userStatusState,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
}
@Composable
private fun ColumnScope.MultiAccountSection(
state: PreferencesRootState,
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@BindingContainer
interface UserStatusBindingContainer {
@Binds
fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter<UserStatusState>
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import io.element.android.libraries.matrix.api.user.UserStatus
sealed interface UserStatusEvent {
data object OpenPicker : UserStatusEvent
data object DismissPicker : UserStatusEvent
data class SetStatus(val status: UserStatus) : UserStatusEvent
data object OpenCustomInput : UserStatusEvent
data object CancelCustomInput : UserStatusEvent
data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent
data object ClearStatus : UserStatusEvent
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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 dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.launch
@Inject
class UserStatusPresenter(
private val matrixClient: MatrixClient,
) : Presenter<UserStatusState> {
@Composable
override fun present(): UserStatusState {
val userProfile by matrixClient.userProfile.collectAsState()
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: UserStatusEvent) {
when (event) {
UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker
UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
if (raw != null) {
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
} else {
customTextFieldState.clearText()
}
pickerState = UserStatusPickerState.CustomInput(
emoji = raw?.emoji ?: "😀",
textFieldState = customTextFieldState,
)
}
is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
}
UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() }
}
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.UpdateCustomEmoji -> {
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
pickerState = current.copy(emoji = event.emoji)
}
}
}
return UserStatusState(
displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = pickerState,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
data class UserStatusState(
val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState,
val eventSink: (UserStatusEvent) -> Unit,
)
@Immutable
sealed interface UserStatusPickerState {
data object Hidden : UserStatusPickerState
data object ShowingPicker : UserStatusPickerState
data class CustomInput(
val emoji: String,
val textFieldState: TextFieldState,
) : UserStatusPickerState
}
enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) {
IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting),
FOCUS_TIME("💡", R.string.common_user_status_focus_time),
ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road),
BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back),
AWAY("🌴", R.string.common_user_status_away),
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusState> {
override val values get() = sequenceOf(
aUserStatusState(),
aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))),
aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)),
aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker),
aUserStatusState(
displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")),
rawStatus = UserStatus("🌴", "Away"),
pickerState = UserStatusPickerState.ShowingPicker,
),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
)
}
fun aUserStatusState(
displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState(
displayedStatus = displayedStatus,
rawStatus = rawStatus,
pickerState = pickerState,
eventSink = eventSink,
)
@@ -0,0 +1,315 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
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.preferences.impl.R
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UserStatusView(
state: UserStatusState,
modifier: Modifier = Modifier,
) {
when (val pickerState = state.pickerState) {
UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> {
val displayedStatus = state.displayedStatus
if (displayedStatus != null) {
CurrentStatusRow(
displayedStatus = displayedStatus,
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
onClear = { state.eventSink(UserStatusEvent.ClearStatus) },
modifier = modifier,
)
} else {
EmptyStatusRow(
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
modifier = modifier,
)
}
if (pickerState == UserStatusPickerState.ShowingPicker) {
UserStatusPickerBottomSheet(
currentRawStatus = state.rawStatus,
onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) },
onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) },
onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) },
)
}
}
is UserStatusPickerState.CustomInput -> {
CustomStatusInputRow(
emoji = pickerState.emoji,
textFieldState = pickerState.textFieldState,
rawStatus = state.rawStatus,
onConfirm = {
state.eventSink(
UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString()))
)
},
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
modifier = modifier,
)
}
}
}
@Composable
private fun EmptyStatusRow(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.screen_preferences_user_status_placeholder),
modifier = Modifier.padding(vertical = 16.dp),
)
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())),
modifier = modifier.clickable(onClick = onClick),
)
}
@Composable
private fun CurrentStatusRow(
displayedStatus: DisplayedStatus,
onClick: () -> Unit,
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
val (emoji, text) = when (displayedStatus) {
is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text
is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call)
}
ListItem(
headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) },
leadingContent = ListItemContent.Custom { EmojiText(emoji) },
trailingContent = ListItemContent.Custom({
IconButton(
onClick = onClear,
// Remove the extra padding caused by IconButton
modifier = Modifier.offset(x = 12.dp)
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear),
)
}
}),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun EmojiText(emoji: String) {
// Ensure the emoji doesn't scale with fontSize to match icons size
val fontSize = with(LocalDensity.current) { 16.dp.toSp() }
Text(
modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 24.dp),
text = emoji,
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSize),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UserStatusPickerBottomSheet(
currentRawStatus: UserStatus?,
onDismiss: () -> Unit,
onSelectPredefinedStatus: (UserStatus) -> Unit,
onSelectCustomStatus: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded)
)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
scrollable = true,
modifier = modifier,
) {
PredefinedUserStatus.entries.forEach { predefined ->
val label = stringResource(predefined.labelRes)
val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label)
val isSelected = currentRawStatus == predefinedUserStatus
ListItem(
headlineContent = { Text(text = label) },
leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) },
trailingContent = if (isSelected) {
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary)
} else {
null
},
onClick = {
sheetState.hide(coroutineScope) {
onSelectPredefinedStatus(predefinedUserStatus)
}
},
)
}
ListItem(
headlineContent = {
Text(text = stringResource(R.string.common_user_status_custom))
},
leadingContent = ListItemContent.Custom { EmojiText("✏️") },
onClick = {
sheetState.hide(coroutineScope) {
onSelectCustomStatus()
}
},
)
}
}
private const val CUSTOM_STATUS_TEXT_MAX_LENGTH = 30
@Composable
private fun CustomStatusInputRow(
emoji: String,
textFieldState: TextFieldState,
rawStatus: UserStatus?,
onConfirm: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } }
val hasChanges by remember(emoji, rawStatus) {
derivedStateOf {
val text = textFieldState.text.toString()
text.isNotBlank() && UserStatus(emoji, text) != rawStatus
}
}
val saveLabel = stringResource(CommonStrings.action_save)
val cancelLabel = stringResource(CommonStrings.action_cancel)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
ListItem(
headlineContent = {
TextField(
modifier = Modifier.focusRequester(focusRequester),
state = textFieldState,
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint),
inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onKeyboardAction = { if (hasChanges) onConfirm() },
lineLimits = TextFieldLineLimits.SingleLine,
trailingIcon = if (hasText) {
{
Box(modifier = Modifier.clickable { textFieldState.clearText() }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel),
modifier = Modifier.size(22.dp),
)
}
}
} else {
null
}
)
},
trailingContent = ListItemContent.Custom({
// Layout measures both buttons to determine the max natural width,
// then places only the active one.
Layout(
content = {
TextButton(onClick = onConfirm, text = saveLabel)
TextButton(onClick = onCancel, text = cancelLabel)
}
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val maxWidth = placeables.maxOf { it.width }
val maxHeight = placeables.maxOf { it.height }
layout(maxWidth, maxHeight) {
val active = if (hasChanges) placeables[0] else placeables[1]
active.placeRelative(
x = (maxWidth - active.width) / 2,
y = (maxHeight - active.height) / 2,
)
}
}
}),
leadingContent = ListItemContent.Custom({
Surface(
shape = CircleShape,
border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary),
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.clickable { },
) {
Box(contentAlignment = Alignment.Center) {
EmojiText(emoji)
}
}
}),
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview {
UserStatusView(state = state)
}
@@ -59,6 +59,7 @@
<string name="screen_labs_header_title">"Jeste li spremni za eksperimentiranje?"</string>
<string name="screen_labs_title">"Laboratoriji"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Dodatne postavke"</string>
<string name="screen_notification_settings_call_ringtone_label">"Ton zvona"</string>
<string name="screen_notification_settings_calls_label">"Audiopozivi i videopozivi"</string>
<string name="screen_notification_settings_configuration_mismatch">"Neusklađenost konfiguracije"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Pojednostavili smo postavke obavijesti kako bismo olakšali pronalaženje mogućnosti. Neke prilagođene postavke koje ste odabrali u prošlosti nisu ovdje prikazane, ali su i dalje aktivne.
@@ -77,14 +78,31 @@ Ako nastavite, neke od vaših postavki mogu se promijeniti."</string>
<string name="screen_notification_settings_invite_for_me_label">"Pozivnice"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; možda nećete dobiti obavijesti u nekim sobama."</string>
<string name="screen_notification_settings_mentions_section_title">"Spominjanja"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Odaberite drugi zvuk…"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Trenutačno upotrebljavate %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"Zvuk poruke"</string>
<string name="screen_notification_settings_message_sound_label">"Zvuk poruke"</string>
<string name="screen_notification_settings_mode_all">"Sve"</string>
<string name="screen_notification_settings_mode_mentions">"Spominjanja"</string>
<string name="screen_notification_settings_notification_section_title">"Obavijesti me za"</string>
<string name="screen_notification_settings_room_mention_label">"Obavijesti me o sobi @soba"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Prilagođeno"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Personalizirano zvono"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Pogreška pri brisanju datoteke"</string>
<string name="screen_notification_settings_sound_element_default">"Zadano za element"</string>
<string name="screen_notification_settings_sound_element_fade">"Blijeđenje elementa"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"Pogreška pri uvozu datoteke"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"Problem s pregledom zvuka upozorenja"</string>
<string name="screen_notification_settings_sound_section_title">"Zvuk"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Pogreška zvuka odbacivanja upozorenja"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"Problem s postavljanjem zvuka upozorenja"</string>
<string name="screen_notification_settings_sound_silent">"Stišano"</string>
<string name="screen_notification_settings_sound_system_default">"Zadano sustavom"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Kako biste primali obavijesti, promijenite %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"postavke sustava"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Obavijesti sustava su isključene"</string>
<string name="screen_notification_settings_title">"Obavijesti"</string>
<string name="theme_black">"Crna"</string>
<string name="theme_dark">"Tamno"</string>
<string name="theme_light">"Svijetlo"</string>
<string name="theme_system">"Sustav"</string>
@@ -10,6 +10,14 @@
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL tidak valid, pastikan Anda menyertakan protokol (http/https) dan alamat yang benar."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Sembunyikan avatar dalam permintaan undangan ruangan"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Sembunyikan pratinjau media pada lini masa"</string>
<string name="screen_advanced_settings_labs">"Uji Coba"</string>
<string name="screen_advanced_settings_live_location_section_description">"Jarak yang harus Anda tempuh untuk memicu pembaruan."</string>
<string name="screen_advanced_settings_live_location_section_footer">"Pastikan \"Lokasi Tepat\" diaktifkan untuk aplikasi ini. Untuk mengubah izin tersebut, buka %1$s."</string>
<string name="screen_advanced_settings_live_location_section_footer_link">"Pengaturan aplikasi"</string>
<string name="screen_advanced_settings_live_location_section_title">"Pembaruan lokasi secara langsung"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="other">"Setiap %1$d meter"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"Unggah foto dan video lebih cepat dan kurangi penggunaan data"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimalkan kualitas media"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderasi dan Keamanan"</string>
@@ -43,6 +51,11 @@
<string name="screen_edit_profile_error_title">"Tidak dapat memperbarui profil"</string>
<string name="screen_edit_profile_title">"Edit profil"</string>
<string name="screen_edit_profile_updating_details">"Memperbarui profil…"</string>
<string name="screen_labs_enable_threads">"Aktifkan balasan utas"</string>
<string name="screen_labs_enable_threads_description">"Aplikasi akan dimulai ulang untuk menerapkan perubahan ini."</string>
<string name="screen_labs_header_description">"Cobalah ide-ide terbaru kami yang sedang dikembangkan. Fitur-fitur ini belum final; mungkin masih tidak stabil dan dapat berubah."</string>
<string name="screen_labs_header_title">"Merasa ingin bereksperimen?"</string>
<string name="screen_labs_title">"Uji Coba"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Pengaturan tambahan"</string>
<string name="screen_notification_settings_calls_label">"Panggilan audio dan video"</string>
<string name="screen_notification_settings_configuration_mismatch">"Ketidakcocokan pengaturan"</string>
@@ -68,10 +81,12 @@ Jika Anda melanjutkan, beberapa pengaturan Anda dapat berubah."</string>
<string name="screen_notification_settings_mode_mentions">"Sebutan"</string>
<string name="screen_notification_settings_notification_section_title">"Beri tahu saya tentang"</string>
<string name="screen_notification_settings_room_mention_label">"Beri tahu saya pada @room"</string>
<string name="screen_notification_settings_sound_element_default">"Pengaturan Default Element"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Untuk menerima pemberitahuan, silakan ubah %1$s Anda."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"pengaturan sistem"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Pemberitahuan sistem dimatikan"</string>
<string name="screen_notification_settings_title">"Notifikasi"</string>
<string name="theme_black">"Hitam"</string>
<string name="theme_dark">"Gelap"</string>
<string name="theme_light">"Terang"</string>
<string name="theme_system">"Sistem"</string>
@@ -31,7 +31,7 @@
<string name="screen_advanced_settings_optimise_video_upload_quality_title">"Qualità del caricamento video"</string>
<string name="screen_advanced_settings_push_provider_android">"Fornitore di notifiche push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Disattiva l\'editor di testo avanzato per scrivere manualmente in Markdown"</string>
<string name="screen_advanced_settings_send_read_receipts">"Conferme di visualizzazione"</string>
<string name="screen_advanced_settings_send_read_receipts">"Conferme di lettura"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Se disattivato, le tue conferme di visualizzazione non verranno inviate a nessuno. Riceverai comunque conferme di visualizzazione da altri utenti."</string>
<string name="screen_advanced_settings_share_presence">"Condividi presenza online"</string>
<string name="screen_advanced_settings_share_presence_description">"Se disattivato, non potrai né inviare né ricevere conferme di lettura o notifiche di scrittura."</string>
@@ -58,6 +58,7 @@
<string name="screen_labs_header_title">"Hai voglia di sperimentare?"</string>
<string name="screen_labs_title">"Labs"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Impostazioni aggiuntive"</string>
<string name="screen_notification_settings_call_ringtone_label">"Suoneria di chiamata"</string>
<string name="screen_notification_settings_calls_label">"Chiamate audio e video"</string>
<string name="screen_notification_settings_configuration_mismatch">"Mancata corrispondenza di configurazione"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Abbiamo semplificato le impostazioni di notifica per rendere le opzioni più facili da trovare. Alcune impostazioni personalizzate che hai scelto in passato non sono mostrate qui, ma sono ancora attive.
@@ -76,14 +77,31 @@ Se procedi, alcune delle tue impostazioni potrebbero cambiare."</string>
<string name="screen_notification_settings_invite_for_me_label">"Inviti"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Il tuo homeserver non supporta questa opzione nelle stanze crifrate, quindi potresti non ricevere notifiche in alcune stanze."</string>
<string name="screen_notification_settings_mentions_section_title">"Menzioni"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Scegli un altro suono…"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Attualmente si utilizza %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"Suono messaggi"</string>
<string name="screen_notification_settings_message_sound_label">"Suono messaggi"</string>
<string name="screen_notification_settings_mode_all">"Tutto"</string>
<string name="screen_notification_settings_mode_mentions">"Menzioni"</string>
<string name="screen_notification_settings_notification_section_title">"Avvisami per"</string>
<string name="screen_notification_settings_room_mention_label">"Avvisami con @room"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Personalizzato"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Suono personalizzato…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Errore durante l\'eliminazione del file"</string>
<string name="screen_notification_settings_sound_element_default">"Predefinito di Element"</string>
<string name="screen_notification_settings_sound_element_fade">"Dissolvenza di Element"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"Errore durante l\'importazione del file"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"Problema durante l\'anteprima del suono di avviso"</string>
<string name="screen_notification_settings_sound_section_title">"Suono"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Ignora l\'errore relativo al suono di avviso"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"Problema con l\'impostazione del suono di avviso"</string>
<string name="screen_notification_settings_sound_silent">"Silenzioso"</string>
<string name="screen_notification_settings_sound_system_default">"Predefinito"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Per ricevere notifiche, modifica le tue %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"impostazioni di sistema"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Notifiche di sistema disattivate"</string>
<string name="screen_notification_settings_title">"Notifiche"</string>
<string name="theme_black">"Nero"</string>
<string name="theme_dark">"Scuro"</string>
<string name="theme_light">"Chiaro"</string>
<string name="theme_system">"Sistema"</string>
@@ -11,6 +11,14 @@
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Xonaga taklif so‘rovlarida avatarlarni berkitish"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Vaqt jadvalida mediaga razm solishlarni berkitish"</string>
<string name="screen_advanced_settings_labs">"Laboratoriyalar"</string>
<string name="screen_advanced_settings_live_location_section_description">"Yangilanishni ishga tushirish uchun harakat qilishingiz kerak boʻlgan masofa"</string>
<string name="screen_advanced_settings_live_location_section_footer">"Ushbu ilova uchun “Aniq joylashuv” yoqilganligiga ishonch hosil qiling. Ruxsatni oʻzgartirish uchun %1$s ga oʻting."</string>
<string name="screen_advanced_settings_live_location_section_footer_link">"Ilova sozlamalari"</string>
<string name="screen_advanced_settings_live_location_section_title">"Jonli joylashuv yangilanishlari"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="one">"Har %1$d metr"</item>
<item quantity="other">"Har %1$d metr"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"Rasm va videolarni tezroq yuklang va trafik sarfini kamaytiring"</string>
<string name="screen_advanced_settings_media_compression_title">"Media sifatini yaxshilash"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderatsiya va xavfsizlik"</string>
@@ -50,6 +58,7 @@
<string name="screen_labs_header_title">"Tajribani his qilyapsizmi?"</string>
<string name="screen_labs_title">"Laboratoriyalar"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Qo\'shimcha sozlamalar"</string>
<string name="screen_notification_settings_call_ringtone_label">"Qoʻngʻroq ohangi"</string>
<string name="screen_notification_settings_calls_label">"Audio va video qo\'ng\'iroqlar"</string>
<string name="screen_notification_settings_configuration_mismatch">"Konfiguratsiya mos kelmasligi"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Variantlarni topishni osonlashtirish uchun bildirishnomalar sozlamalarini soddalashtirdik. Ilgari siz tanlagan baʼzi shaxsiy sozlamalar bu yerda koʻrsatilmaydi, lekin ular hali ham faol.
@@ -68,14 +77,31 @@ Davom ettirsangiz, baʼzi sozlamalaringiz oʻzgarishi mumkin."</string>
<string name="screen_notification_settings_invite_for_me_label">"Taklifnomalar"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Uy serveringiz shifrlangan xonalarda ushbu imkoniyatni qoʻllab-quvvatlamaydi, shuning uchun baʼzi xonalardagi xabarlarni olmasligingiz mumkin."</string>
<string name="screen_notification_settings_mentions_section_title">"Eslatmalar"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Boshqa tovushni tanlang…"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Hozir foydalanilmoqda: %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"Xabar ovozi"</string>
<string name="screen_notification_settings_message_sound_label">"Xabar ovozi"</string>
<string name="screen_notification_settings_mode_all">"Hammasi"</string>
<string name="screen_notification_settings_mode_mentions">"Eslatmalar"</string>
<string name="screen_notification_settings_notification_section_title">"Menga xabar bering"</string>
<string name="screen_notification_settings_room_mention_label">"Menga @room orqali xabar bering"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Maxsus"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Maxsus ovoz…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Faylni oʻchirishda xatolik yuz berdi"</string>
<string name="screen_notification_settings_sound_element_default">"Element standarti"</string>
<string name="screen_notification_settings_sound_element_fade">"Elementlarning yoʻqolishi"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"Faylni import qilishda xatolik yuz berdi"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"Ogohlantirish ovozini oldindan koʻrishda muammo"</string>
<string name="screen_notification_settings_sound_section_title">"Ovoz"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Ogohlantirish ovozi xatosini yopish"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"Ogohlantirish ovozini sozlashda muammo"</string>
<string name="screen_notification_settings_sound_silent">"Ovozsiz"</string>
<string name="screen_notification_settings_sound_system_default">"Tizim standarti"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Bildirishnomalarni olish uchun, iltimos, o\'zingizni %1$singizni o\'zgartiring."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"tizim sozlamalari"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Tizim bildirishnomalari o\'chirilgan"</string>
<string name="screen_notification_settings_title">"Bildirishnomalar"</string>
<string name="theme_black">"Qora"</string>
<string name="theme_dark">"Tungi"</string>
<string name="theme_light">"Nur"</string>
<string name="theme_system">"Tizim"</string>
@@ -68,7 +68,7 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_edit_custom_settings_section_title">"Custom setting per chat"</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"An error occurred while updating the notification setting."</string>
<string name="screen_notification_settings_edit_mode_all_messages">"All messages"</string>
<string name="screen_notification_settings_edit_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_notification_settings_edit_mode_mentions_and_keywords">"Mentions and replies only"</string>
<string name="screen_notification_settings_edit_screen_direct_section_header">"On direct chats, notify me for"</string>
<string name="screen_notification_settings_edit_screen_group_section_header">"On group chats, notify me for"</string>
<string name="screen_notification_settings_enable_notifications">"Enable notifications on this device"</string>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="common_user_status_in_a_meeting">In a meeting</string>
<string name="common_user_status_focus_time">Focus time</string>
<string name="common_user_status_on_the_road">On the road</string>
<string name="common_user_status_be_right_back">Be right back</string>
<string name="common_user_status_away">Away</string>
<string name="common_user_status_on_a_call">On a call</string>
<string name="common_user_status_custom">Custom…</string>
<string name="screen_preferences_user_status_placeholder">What\'s your status?</string>
<string name="screen_preferences_user_status_custom_hint">Custom status…</string>
</resources>
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.core.meta.BuildType
@@ -346,5 +347,6 @@ class PreferencesRootPresenterTest {
featureFlagService = featureFlagService,
sessionStore = sessionStore,
sessionEnterpriseService = sessionEnterpriseService,
userStatusPresenter = { aUserStatusState() },
)
}
@@ -12,8 +12,11 @@ package io.element.android.features.preferences.impl.root
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -199,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenAnalytics = callback,
)
clickOn(CommonStrings.common_analytics)
val text = activity!!.getString(CommonStrings.common_analytics)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -226,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenRageShake = callback,
)
clickOn(CommonStrings.common_report_a_problem)
val text = activity!!.getString(CommonStrings.common_report_a_problem)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -281,7 +286,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onOpenDeveloperSettings = callback,
)
clickOn(CommonStrings.common_developer_options)
val text = activity!!.getString(CommonStrings.common_developer_options)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -389,7 +395,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onSignOutClick = callback,
)
clickOn(CommonStrings.action_signout)
val text = activity!!.getString(CommonStrings.action_signout)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -404,7 +411,8 @@ class PreferencesRootViewTest : RobolectricTest() {
),
onDeactivateClick = callback,
)
clickOn(CommonStrings.action_delete_account)
val text = activity!!.getString(CommonStrings.action_delete_account)
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
}
}
@@ -430,7 +438,7 @@ class PreferencesRootViewTest : RobolectricTest() {
eventSink = eventsRecorder,
),
)
onNodeWithText(version).performClick()
onNodeWithText(version).performScrollTo().performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class UserStatusPresenterTest {
private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) =
UserStatusPresenter(matrixClient = client)
@Test
fun `initial state - no status set`() = runTest {
createPresenter().test {
val state = awaitItem()
assertThat(state.displayedStatus).isNull()
assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Open event transitions to ShowingPicker`() = runTest {
createPresenter().test {
val state = awaitItem()
state.eventSink(UserStatusEvent.OpenPicker)
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker)
}
}
@Test
fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest {
createPresenter().test {
val state = awaitItem()
state.eventSink(UserStatusEvent.OpenPicker)
awaitItem() // ShowingPicker
state.eventSink(UserStatusEvent.DismissPicker)
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest {
val client = FakeMatrixClient()
createPresenter(client).test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `OpenCustomInput with no existing status uses defaults`() = runTest {
createPresenter().test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.emoji).isEqualTo("😀")
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("")
}
}
@Test
fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest {
val client = FakeMatrixClient()
createPresenter(client).test {
val status = UserStatus(emoji = "🌴", text = "Away")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.emoji).isEqualTo("🌴")
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away")
}
}
@Test
fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest {
createPresenter().test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("🚀"))
val state = awaitItem()
assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("🚀")
}
}
@Test
fun `CancelCustomInput goes back to Hidden`() = runTest {
createPresenter().test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Clear event calls clearUserStatus and clears displayed status`() = runTest {
val client = FakeMatrixClient()
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull()
assertThat(client.clearUserStatusCalled).isTrue()
}
}
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?"</string>
<string name="crash_detection_dialog_content">"%1$s oxirgi marta ishlatilganda xatolik berdi. Biz bilan nosozlik hisobotini ulashmoqchimisiz?"</string>
<string name="rageshake_detection_dialog_content">"Siz hafsalasi pir bo\'lib telefonni silkitayotganga o\'xshaysiz. Xatolar haqida hisobot ekranini ochmoqchimisiz?"</string>
<string name="settings_rageshake">"G\'azablanish"</string>
<string name="settings_rageshake_detection_threshold">"Aniqlash chegarasi"</string>
@@ -8,6 +8,8 @@
<string name="screen_bug_report_editor_placeholder">"Opišite problem…"</string>
<string name="screen_bug_report_editor_supporting">"Ako je moguće, molimo vas da opis bude na engleskom jeziku."</string>
<string name="screen_bug_report_error_description_too_short">"Opis je prekratak; navedite više pojedinosti o tome što se dogodilo. Hvala!"</string>
<string name="screen_bug_report_github_issue_description">"Možete unijeti broj povezanog GitHub problema, ako postoji."</string>
<string name="screen_bug_report_github_issue_label">"Problem s GitHubom"</string>
<string name="screen_bug_report_include_crash_logs">"Pošalji zapisnike o padu aplikacije"</string>
<string name="screen_bug_report_include_logs">"Dopusti zapisnike"</string>
<string name="screen_bug_report_include_logs_error">"Vaši su zapisnici preopširni pa ih nije moguće uključiti u ovo izvješće. Molimo vas da nam ih pošaljete na drugi način."</string>

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