mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into feature/valere/timeline_active_call_rendering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
+49
-18
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+13
-8
@@ -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,
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+4
@@ -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 = {}
|
||||
)
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+5
@@ -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>
|
||||
}
|
||||
|
||||
+96
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -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,
|
||||
)
|
||||
-128
@@ -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()
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+127
@@ -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()
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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
|
||||
+21
@@ -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,
|
||||
)
|
||||
+11
-5
@@ -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())
|
||||
|
||||
+17
-18
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
|
||||
+5
-4
@@ -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,
|
||||
)
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
+13
-10
@@ -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,
|
||||
)
|
||||
|
||||
+2
-3
@@ -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,
|
||||
)
|
||||
|
||||
+4
-4
@@ -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,
|
||||
)
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
+9
-12
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-3
@@ -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)
|
||||
}
|
||||
|
||||
+5
-4
@@ -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
|
||||
)
|
||||
|
||||
+15
-3
@@ -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
|
||||
|
||||
+13
-10
@@ -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,
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
|
||||
+4
-4
@@ -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,
|
||||
)
|
||||
|
||||
+14
-3
@@ -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 > Keamanan &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>
|
||||
|
||||
+80
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+10
-10
@@ -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,
|
||||
)
|
||||
|
||||
+3
-2
@@ -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()
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+9
-9
@@ -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(
|
||||
|
||||
+38
-38
@@ -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,
|
||||
|
||||
+18
-8
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+6
-5
@@ -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 & 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 & 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>
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+7
-1
@@ -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,
|
||||
)
|
||||
|
||||
+27
-3
@@ -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,
|
||||
|
||||
+21
@@ -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>
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
+75
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -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),
|
||||
}
|
||||
+41
@@ -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,
|
||||
)
|
||||
+315
@@ -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>
|
||||
+2
@@ -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() },
|
||||
)
|
||||
}
|
||||
|
||||
+14
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+123
@@ -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
Reference in New Issue
Block a user