diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml deleted file mode 100644 index b07fa5cc09..0000000000 --- a/.maestro/tests/account/changeServer.yaml +++ /dev/null @@ -1,21 +0,0 @@ -appId: ${MAESTRO_APP_ID} ---- -- tapOn: - id: "login-change_server" -- takeScreenshot: build/maestro/200-ChangeServer -- tapOn: "matrix.org" -- tapOn: - id: "login-change_server" -- tapOn: "Other" -- tapOn: - id: "change_server-server" -- inputText: "element" -- hideKeyboard -- extendedWaitUntil: - visible: "element.io" - timeout: 10000 -- tapOn: "element.io" -# Revert to matrix.org -- tapOn: - id: "login-change_server" -- tapOn: "matrix.org" diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 873843b22d..81922c7f38 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID} - tapOn: "Sign in manually" - runFlow: ../assertions/assertLoginDisplayed.yaml - takeScreenshot: build/maestro/100-SignIn -- runFlow: changeServer.yaml -- runFlow: ../assertions/assertLoginDisplayed.yaml +# The account provider field defaults to matrix.org, so continue straight to the MAS login. - tapOn: id: "login-continue" ## MAS page diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml index 6d8558c38e..c32f23c323 100644 --- a/.maestro/tests/assertions/assertLoginDisplayed.yaml +++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml @@ -1,5 +1,5 @@ appId: ${MAESTRO_APP_ID} --- - extendedWaitUntil: - visible: "Change account provider" + visible: "Enter your account provider" timeout: 10000 diff --git a/CHANGES.md b/CHANGES.md index 2955fc8ad7..db18e59251 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,86 @@ +Changes in Element X v26.08.2 +============================= + + + +**Note: this release in an internal release, it will not be pushed to the stores.** + +## What's Changed +### ✨ Features +* Remove the legacy web-based account creation for matrix.org by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7412 +### 🙌 Improvements +* Move the custom tile server URL config to the Element wellknown content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7416 +* Explain a rate limiting push gateway in the notification troubleshooter by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7448 +* Move the `:libraries:wellknown` logic into `enterprise` modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7429 +* Refresh the room members when a mention is started by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7438 +* Close the invite screen once the invite has been declined by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7440 +* Use Profiles sliding sync extension check for own profile subscription by @ganfra in https://github.com/element-hq/element-x-android/pull/7403 +* Simplify choosing the account provider during sign in, with autocomplete of previously used ones by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7394 +### 🐛 Bugfixes +* Fix wrong TextComposer placeholder displayed when in a thread. by @bmarty in https://github.com/element-hq/element-x-android/pull/7413 +* Fix bug reports failing to send after the app has crashed by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7395 +* Fix outgoing session verification stuck with incomplete recovery setup by @hardcore-sushi in https://github.com/element-hq/element-x-android/pull/7433 +* Keep transparency when an image is shared into the app with an imprecise mime type by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7452 +* Don't restart the sync service if the client is shutting down by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7472 +* Normalize the applicationName used in user agent by @bmarty in https://github.com/element-hq/element-x-android/pull/7477 +* Keep playing a voice message when another app plays a short sound by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7454 +* Keep the send button when the composer is emptied while editing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7456 +* Do not open the room again when the app is resumed from the recents list by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7436 +* Stop waiting for a room alias that the remote server never resolves by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7446 +### 🧱 Build +* build-rust-sdk: print the command we run by @richvdh in https://github.com/element-hq/element-x-android/pull/7407 +* Remove license check on Pro modules. by @bmarty in https://github.com/element-hq/element-x-android/pull/7418 +* Improve build time config and update the Firebase configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/7473 +* Make the PUSHER_APP_ID configurable at build time. by @bmarty in https://github.com/element-hq/element-x-android/pull/7478 +### 📄 Documentation +* Add KDoc to all the interfaces of the api modules by @bmarty in https://github.com/element-hq/element-x-android/pull/7426 +### 🚧 In development 🚧 +* [LinkNewDevice] Ensure the notice screen is also protected by the timeout. by @bmarty in https://github.com/element-hq/element-x-android/pull/7428 +### Dependency upgrades +* Update kotlin to v2.3.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7396 +* Update plugin sonarqube to v7.4.0.8496 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7415 +* Update metro to v1.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7421 +* Update roborazzi to v1.71.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7423 +* Update dependency io.sentry:sentry-android to v8.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7414 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7420 +* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7431 +* Update dependency org.maplibre.compose:maplibre-compose to v0.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7432 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7460 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7479 +* Update media3 to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7427 +* Update dependency androidx.compose:compose-bom to v2026.08.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7483 +### 💼 Element Pro +* Add hooks to allow providing additional feature flags in EX pro by @richvdh in https://github.com/element-hq/element-x-android/pull/7419 +* Use the right base URL for the ESS config/well known endpoints by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7469 +### Others +* Do not display pinned events that the client cannot render by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7379 +* Hide the forward action on live location shares by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7343 +* Do not fail the screenshot recording job when there is nothing to commit by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7355 +* Show the account a UnifiedPush registration belongs to in the distributor by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7345 +* Stop repeating messages that are already shown in a room notification by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7370 +* Turn a matrix: URI in a message into a pill by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7354 +* Send a message with Enter on a physical keyboard, Shift+Enter for a new line by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7353 +* Do not group timeline messages sent more than five minutes apart by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7349 +* Linkify URLs whose host is a bracketed IPv6 address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7347 +* Add missing tests for `RustTemporaryMatrixClient` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7417 +* Revert "Keep manually revealed media visible when re-entering a room" by @bmarty in https://github.com/element-hq/element-x-android/pull/7424 +* Fix outdated documentation. by @bmarty in https://github.com/element-hq/element-x-android/pull/7430 +* Sync strings - New language supported: Arabic by @bmarty in https://github.com/element-hq/element-x-android/pull/7461 +* Keep the focused login field visible above the keyboard by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7455 +* Relax image compression so photos and screenshots keep more detail by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7451 +* Open the user profile of a user id that contains a slash by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7445 +* Remove the F-Droid dex patching step from the release script by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7442 +* Convert markdown to HTML when a caption is edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7441 +* Exclude carriage returns from the login and password fields by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7439 +* Show an error when the room to leave cannot be found by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7437 +* Give the room name in the timeline top bar a 48dp tap target by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7450 +* Model the fake voice recorder on a cancellable recording job by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7466 + +## New Contributors +* @hardcore-sushi made their first contribution in https://github.com/element-hq/element-x-android/pull/7433 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.1...v26.08.2 + Changes in Element X v26.08.1 ============================= diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ecd5ed8e7b..f2c7d37baa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -259,6 +259,7 @@ dependencies { if (isEnterpriseBuild) { allEnterpriseImpl(project) implementation(projects.appicon.enterprise) + implementation(project(":enterprise:features:enterprise:shared")) } else { implementation(projects.features.enterprise.implFoss) implementation(projects.appicon.element) diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index 7564db5d95..39c70e35d8 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -18,7 +18,7 @@ import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn import io.element.android.appconfig.ApplicationConfig -import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta @@ -74,17 +74,18 @@ object AppModule { fun providesBuildMeta( @ApplicationContext context: Context, buildType: BuildType, - enterpriseService: EnterpriseService, + isEnterpriseBuild: IsEnterpriseBuild, ): BuildMeta { val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name) + val isEnterprise = isEnterpriseBuild() return BuildMeta( isDebuggable = BuildConfig.DEBUG, buildType = buildType, applicationName = applicationName, - productionApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME, - desktopApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME, + productionApplicationName = if (isEnterprise) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME, + desktopApplicationName = if (isEnterprise) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME, applicationId = BuildConfig.APPLICATION_ID, - isEnterpriseBuild = enterpriseService.isEnterpriseBuild, + isEnterpriseBuild = isEnterprise, // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE, lowPrivacyLoggingEnabled = false, versionName = BuildConfig.VERSION_NAME, diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt deleted file mode 100644 index d36fb742b0..0000000000 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.appconfig - -object PushConfig { - /** - * Note: pusher_app_id cannot exceed 64 chars. - */ - const val PUSHER_APP_ID: String = "im.vector.app.android" -} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index d7b768132b..cd071131d9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -51,7 +51,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState @@ -157,7 +157,7 @@ class LoggedInFlowNode( private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, + private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -685,7 +685,7 @@ class LoggedInFlowNode( val currentMapTilerConfig = LocalMapTilerConfig.current val updatedMapTilerConfig by produceState(currentMapTilerConfig) { - value = remoteEnterpriseConfigProvider.get(matrixClient.sessionId).dataOrNull()?.mapTilerConfig ?: currentMapTilerConfig + value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig } ElementThemeApp( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt index 11337f2e0a..a4bccfd6d7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt @@ -115,7 +115,7 @@ class MatrixSessionCache( private fun onNewMatrixClient(matrixClient: MatrixClient) { val syncOrchestrator = syncOrchestratorFactory.create( - syncService = matrixClient.syncService, + matrixClient = matrixClient, sessionCoroutineScope = matrixClient.sessionCoroutineScope, ) sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt index 1ce423a569..35a6a224ae 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt @@ -16,7 +16,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope -import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.recordTransaction @@ -37,7 +37,7 @@ import kotlin.time.Duration.Companion.seconds @AssistedInject class SyncOrchestrator( - @Assisted private val syncService: SyncService, + @Assisted private val matrixClient: MatrixClient, @Assisted sessionCoroutineScope: CoroutineScope, private val appForegroundStateService: AppForegroundStateService, private val networkMonitor: NetworkMonitor, @@ -47,13 +47,15 @@ class SyncOrchestrator( @AssistedFactory interface Factory { fun create( - syncService: SyncService, + matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope, ): SyncOrchestrator } private val tag = "SyncOrchestrator" + private val syncService = matrixClient.syncService + private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag) private val started = AtomicBoolean(false) @@ -94,7 +96,7 @@ class SyncOrchestrator( appForegroundStateService.isInCall, appForegroundStateService.isSyncingNotificationEvent, appForegroundStateService.hasRingingCall, - appForegroundStateService.isSharingLiveLocation + appForegroundStateService.isSharingLiveLocation, ) val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } } combine( @@ -103,6 +105,11 @@ class SyncOrchestrator( networkMonitor.connectivity, isAppActiveFlow, ) { syncState, networkState, isAppActive -> + if (matrixClient.isShuttingDown) { + Timber.tag(tag).d("Matrix client is shutting down, no need to start the sync service again") + return@combine SyncStateAction.NoOp + } + val isNetworkAvailable = networkState == NetworkStatus.Connected Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable") diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt index ee316f00aa..5d3a85fa9a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -76,6 +76,7 @@ class IntentResolver( } private fun Intent.canBeIgnored(): Boolean { + if (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) return true return action == Intent.ACTION_MAIN && categories?.contains(Intent.CATEGORY_LAUNCHER) == true } diff --git a/appnav/src/main/res/values-ar/translations.xml b/appnav/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..ca3a804097 --- /dev/null +++ b/appnav/src/main/res/values-ar/translations.xml @@ -0,0 +1,6 @@ + + + "تسجيل الخروج والترقية" + "لم يعد %1$s يدعم البروتوكول القديم. يرجى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لمتابعة استخدام التطبيق." + "لم يعد خادمك المنزلي يدعم البروتوكول القديم. يرجى تسجيل الخروج ثم تسجيل الدخول مرة أخرى لمتابعة استخدام التطبيق." + diff --git a/appnav/src/main/res/values-fa/translations.xml b/appnav/src/main/res/values-fa/translations.xml index 16bb7fd3e2..dfff863365 100644 --- a/appnav/src/main/res/values-fa/translations.xml +++ b/appnav/src/main/res/values-fa/translations.xml @@ -2,4 +2,5 @@ "خروج و ارتقا" "‏%1$s دیگر از شیوه‌نامهٔ قدیمی پشتیبانی نمی‌کند. لطفاً برای ادامهٔ استفاده از کاره، خارج شده و دوباره وارد شوید." + "سرور خانگی شما دیگر از پروتکل قدیمی پشتیبانی نمی‌کند. لطفاً برای ادامه استفاده از برنامه، از سیستم خارج شده و دوباره وارد شوید." diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index 7309ca6ac2..ab19ae0b54 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -12,6 +12,7 @@ import io.element.android.appnav.di.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService @@ -40,7 +41,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -60,7 +61,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -85,7 +86,7 @@ class SyncOrchestratorTest { val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -117,7 +118,7 @@ class SyncOrchestratorTest { val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -165,7 +166,7 @@ class SyncOrchestratorTest { initialIsSyncingNotificationEventValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -208,7 +209,7 @@ class SyncOrchestratorTest { initialIsSyncingNotificationEventValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -252,7 +253,7 @@ class SyncOrchestratorTest { initialHasRingingCall = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -296,7 +297,7 @@ class SyncOrchestratorTest { initialIsInCallValue = true, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -339,7 +340,7 @@ class SyncOrchestratorTest { initialIsInCallValue = false, ) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, ) @@ -369,7 +370,7 @@ class SyncOrchestratorTest { } val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) val syncOrchestrator = createSyncOrchestrator( - syncService = syncService, + matrixClient = FakeMatrixClient(syncService = syncService), networkMonitor = networkMonitor, ) @@ -381,12 +382,44 @@ class SyncOrchestratorTest { startSyncRecorder.assertions().isNeverCalled() } + @Test + fun `when the client is shutting down, the orchestrator does nothing`() = runTest { + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + initialIsSyncingNotificationEventValue = false, + initialIsInCallValue = false, + ) + + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + } + val syncOrchestrator = createSyncOrchestrator( + matrixClient = FakeMatrixClient(syncService = syncService, isShuttingDownResult = { true }), + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // These should still not trigger a sync, sync the client is shutting down + networkMonitor.givenNetworkBlocked(false) + appForegroundStateService.updateIsInCallState(true) + appForegroundStateService.isInForeground.value = true + + advanceTimeBy(10.seconds) + + startSyncRecorder.assertions().isNeverCalled() + } + private fun TestScope.createSyncOrchestrator( - syncService: FakeSyncService = FakeSyncService(), + matrixClient: FakeMatrixClient = FakeMatrixClient(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), ) = SyncOrchestrator( - syncService = syncService, + matrixClient = matrixClient, sessionCoroutineScope = backgroundScope, networkMonitor = networkMonitor, appForegroundStateService = appForegroundStateService, diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt index 36fa471dd0..cba8d06f90 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt @@ -11,7 +11,7 @@ package io.element.android.appnav.di import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat import io.element.android.features.networkmonitor.test.FakeNetworkMonitor -import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService @@ -138,11 +138,11 @@ class MatrixSessionCacheTest { return object : SyncOrchestrator.Factory { override fun create( - syncService: SyncService, + matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope, ): SyncOrchestrator { return SyncOrchestrator( - syncService = syncService, + matrixClient = matrixClient, sessionCoroutineScope = sessionCoroutineScope, appForegroundStateService = FakeAppForegroundStateService(), networkMonitor = FakeNetworkMonitor(), diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 841f79678b..ddeae41a78 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -45,6 +45,24 @@ class IntentResolverTest : RobolectricTest() { assertThat(result).isNull() } + @Test + fun `resolve intent replayed from the history should return null`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = null, + eventId = null, + ) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + addFlags(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) + } + val result = sut.resolve(intent) + assertThat(result).isNull() + } + @Test fun `test resolve navigation intent root`() { val sut = createIntentResolver( diff --git a/enterprise b/enterprise index f5f910856c..c87b257dd8 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit f5f910856cfd4f982d1dcce16a80f8fedfbcbd31 +Subproject commit c87b257dd87fedc9ca6a4001d0318aae5a453284 diff --git a/fastlane/metadata/android/en-US/changelogs/202608020.txt b/fastlane/metadata/android/en-US/changelogs/202608020.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202608020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/analytics/api/src/main/res/values-ar/translations.xml b/features/analytics/api/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..449a0a01cb --- /dev/null +++ b/features/analytics/api/src/main/res/values-ar/translations.xml @@ -0,0 +1,7 @@ + + + "مشاركة بيانات استخدام مجهولة لمساعدتنا على تتبع المشاكل." + "يمكنك قراءة كافة شروطنا %1$s." + "هنا" + "مشاركة البيانات الإحصائية" + diff --git a/features/analytics/api/src/main/res/values-pl/translations.xml b/features/analytics/api/src/main/res/values-pl/translations.xml index a3347582cc..37848b4c3f 100644 --- a/features/analytics/api/src/main/res/values-pl/translations.xml +++ b/features/analytics/api/src/main/res/values-pl/translations.xml @@ -1,6 +1,6 @@ - "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy." + "Udostępniaj anonimowe dane użytkowania, aby pomóc nam ulepszać aplikację." "Przeczytaj nasze warunki użytkowania %1$s." "tutaj" "Udostępniaj dane analityczne" diff --git a/features/analytics/impl/src/main/res/values-ar/translations.xml b/features/analytics/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..8d1bf460d6 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,10 @@ + + + "لن نسجّل أو ننشئ ملفات شخصية لأي معلومات شخصية" + "مشاركة بيانات استخدام مجهولة لمساعدتنا على تتبع المشاكل." + "يمكنك قراءة كافة شروطنا %1$s." + "هنا" + "يمكنك إطفاء هذا الخيار في أي وقت" + "لن نشارك بياناتك مع أطراف ثالثة" + "ساعدنا على التحسن %1$s" + diff --git a/features/analytics/impl/src/main/res/values-pl/translations.xml b/features/analytics/impl/src/main/res/values-pl/translations.xml index 61da8408ad..c58d630ce9 100644 --- a/features/analytics/impl/src/main/res/values-pl/translations.xml +++ b/features/analytics/impl/src/main/res/values-pl/translations.xml @@ -1,7 +1,7 @@ "Nie będziemy rejestrować ani profilować żadnych danych osobistych" - "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy." + "Udostępniaj anonimowe dane użytkowania, aby pomóc nam ulepszać aplikację." "Przeczytaj nasze warunki użytkowania %1$s." "tutaj" "Możesz to wyłączyć w dowolnym momencie" diff --git a/features/call/impl/src/main/res/values-ar/translations.xml b/features/call/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..612dd16a28 --- /dev/null +++ b/features/call/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,8 @@ + + + "مكالمة جارية" + "اضغط للعودة إلى المكالمة" + "☎️ الاتصال يتمّ" + "لا يدعم Element Call استخدام أجهزة الصوت عبر Bluetooth في إصدار Android هذا. يرجى اختيار جهاز صوت آخر." + "مكالمة Element Call واردة" + diff --git a/features/call/impl/src/main/res/values-fa/translations.xml b/features/call/impl/src/main/res/values-fa/translations.xml index c58ebfd5c6..622be3d428 100644 --- a/features/call/impl/src/main/res/values-fa/translations.xml +++ b/features/call/impl/src/main/res/values-fa/translations.xml @@ -3,5 +3,6 @@ "تماس خروجی" "زدن برای بازگشت به تماس" "تماس در جریان ☎️" + "برنامه Element Call در این نسخه اندروید از دستگاه‌های صوتی بلوتوث پشتیبانی نمی‌کند. لطفاً دستگاه صوتی دیگری را انتخاب کنید." "تماس المنتی ورودی" diff --git a/features/createroom/impl/src/main/res/values-ar/translations.xml b/features/createroom/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..7cb356739a --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,36 @@ + + + "إنشاء غرفة جديدة" + "دعوة أشخاص" + "حصل خطأ أثناء إنشاء الغرفة" + "تعذّر إنشاء المساحة بسبب خطأ غير معروف. حاول مرة أخرى لاحقًا." + "إضافة اسم…" + "غرفة جديدة" + "مساحة جديدة" + "لا يدخل إلا المدعوّون." + "خاصة" + "بإمكان الجميع العثور على هذه الغرفة. +يمكنك تغيير هذا من إعدادات الغرفة متى شئت." + "يمكن لأي شخص الانضمام." + "عامة" + "يمكن لأي شخص طلب الانضمام، لكن يجب أن يقبل الطلب مسؤول أو مشرف." + "السماح بطلبات الانضمام" + "يمكن لأي شخص في %1$s الانضمام، لكن يجب على الآخرين طلب الوصول." + "طلب الانضمام" + "يمكن للمدعوين فقط الانضمام." + "خاصة" + "يمكن لأي شخص الانضمام." + "عامة" + "يمكن لأي شخص في %1$s الانضمام." + "قياسي" + "من يمكنه الوصول" + "تحتاج إلى عنوان حتى تظهر الغرفة في الدليل العام." + "العنوان" + "ظهور الغرفة" + "(لا توجد مساحة)" + "عدم الإضافة إلى مساحة" + "لم يتم اختيار أي مساحة" + "إضافة إلى مساحة" + "الموضوع (اختياري)" + "إضافة وصف…" + diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml index 740e0b70cc..e3ceccbb83 100644 --- a/features/createroom/impl/src/main/res/values-es/translations.xml +++ b/features/createroom/impl/src/main/res/values-es/translations.xml @@ -3,14 +3,34 @@ "Nueva sala" "Invitar personas" "Se ha producido un error al crear la sala" + "No se pudo crear el espacio debido a un error desconocido. Inténtalo de nuevo más tarde." + "Añadir nombre…" + "Nueva sala" + "Nuevo espacio" "Solo las personas invitadas pueden acceder." + "Privado" "Cualquiera puede encontrar esta sala. Puedes cambiar esto en cualquier momento en los ajustes de la sala." + "Cualquiera puede unirse." + "Público" "Cualquiera puede solicitar unirse, pero un administrador o un moderador tendrá que aceptar la solicitud" "Permitir solicitar unirse" + "Cualquiera en %1$s puede unirse, pero el resto debe solicitar acceso." + "Solicitar unirse" + "Solo las personas invitadas pueden unirse." + "Privado" "Cualquiera puede unirse." + "Público" + "Cualquiera de %1$s puede unirse." + "Estándar" + "Quién tiene acceso" "Necesitarás una dirección de sala para que sea visible en el directorio de salas públicas." "Dirección" "Visibilidad de la sala" + "(ningún espacio)" + "No añadir a un espacio" + "No se ha seleccionado ningún espacio" + "Añadir a un espacio" "Tema (opcional)" + "Añadir descripción…" diff --git a/features/createroom/impl/src/main/res/values-fa/translations.xml b/features/createroom/impl/src/main/res/values-fa/translations.xml index 821d55af67..c3ec327d5b 100644 --- a/features/createroom/impl/src/main/res/values-fa/translations.xml +++ b/features/createroom/impl/src/main/res/values-fa/translations.xml @@ -3,12 +3,34 @@ "اتاق جدید" "دعوت افراد" "هنگام ایجاد اتاق خطایی رخ داد" + "به دلیل خطای ناشناخته‌ای، فضا ایجاد نشد. بعداً دوباره امتحان کنید." + "افزودن نام…" + "اتاق جدید" + "فضای جدید" "تنها افراد دعوت شده می‌توانند بپیوندند." + "خصوصی" "هرکسی می‌تواند اتاق را بیابد. می‌توانید بعداً در تظیمات اتاق عوضش کنید." - "درخواست دعوت" - "هرکسی می‌تواند به این اتاق بپیوندد" - "نشانی اتاق" + "هر کسی می‌تواند عضو شود." + "عمومی" + "هر کسی می‌تواند درخواست عضویت بدهد، اما یک مدیر یا ناظر باید درخواست را بپذیرد." + "اجازه درخواست عضویت" + "هر کسی در %1$s می‌تواند عضو شود، اما بقیه باید درخواست دسترسی بدهند." + "درخواست عضویت" + "تنها افراد دعوت شده می‌توانند بپیوندند." + "خصوصی" + "هر کسی می‌تواند عضو شود." + "عمومی" + "هر کسی که در %1$s باشد می‌تواند عضو شود." + "استاندارد" + "چه کسی دسترسی دارد" + "برای اینکه بتوانید آن را در فهرست عمومی مشاهده کنید، به یک آدرس نیاز دارید." + "آدرس" "نمایانی اتاق" + "(بدون فضا)" + "به یک فضا اضافه نکنید" + "فضایی انتخاب نشده است" + "به فضا اضافه کنید" "موضوع (اختیاری)" + "اضافه کردن توضیحات…" diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt index 738046b3a3..9b5f0cfd99 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -299,10 +299,10 @@ private fun Content( } /** - * Ensure that the string does not contain any new line characters, which can happen when pasting values. + * Ensure that the string does not contain any line separator, which can happen when pasting values. */ private fun String.sanitize(): String { - return replace("\n", "") + return filterNot { it == '\n' || it == '\r' } } @PreviewsDayNight diff --git a/features/deactivation/impl/src/main/res/values-ar/translations.xml b/features/deactivation/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..d0fa5afe94 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,14 @@ + + + "يرجى تأكيد رغبتك في حذف حسابك. لا يمكن التراجع عن هذا الإجراء." + "حذف جميع رسائلي" + "تحذير: قد يرى المستخدمون في المستقبل محادثات غير مكتملة." + "حذف حسابك %1$s، وسيؤدي إلى:" + "إجراء لا رجعة فيه" + "%1$s (لن تتمكن من تسجيل الدخول مرة أخرى، ولا يمكن إعادة استخدام معرّفك)." + "تعطيل حسابك نهائيًا" + "إزالتك من جميع غرف الدردشة." + "حذف معلومات حسابك من خادم الهوية لدينا." + "ستظل رسائلك مرئية للمستخدمين المسجلين، لكنها لن تكون متاحة للمستخدمين الجدد أو غير المسجلين إذا اخترت حذفها." + "حذف الحساب" + diff --git a/features/deactivation/impl/src/main/res/values-es/translations.xml b/features/deactivation/impl/src/main/res/values-es/translations.xml index 17ae73d6c8..dc3e090e06 100644 --- a/features/deactivation/impl/src/main/res/values-es/translations.xml +++ b/features/deactivation/impl/src/main/res/values-es/translations.xml @@ -1,13 +1,14 @@ - "Confirma que quieres desactivar tu cuenta. Esta acción no se puede deshacer." + "Confirma que quieres eliminar tu cuenta. Esta acción no se puede deshacer." "Borrar todos mis mensajes" "Advertencia: Futuros usuarios pueden ver conversaciones incompletas." - "Desactivar tu cuenta es %1$s:" + "Eliminar tu cuenta es %1$s:" "irreversible" "%1$s tu cuenta (no podrás volver a iniciar sesión y tu ID no se podrá volver a utilizar)." "Inhabilitará permanentemente" "Te eliminará de todas las salas de chat." "Eliminará la información de tu cuenta de nuestro servidor de identidad." "Tus mensajes seguirán siendo visibles para los usuarios registrados, pero no estarán disponibles para los usuarios nuevos o no registrados si decides eliminarlos." + "Eliminar cuenta" diff --git a/features/deactivation/impl/src/main/res/values-fa/translations.xml b/features/deactivation/impl/src/main/res/values-fa/translations.xml index 5c4eb00d62..38828d5e60 100644 --- a/features/deactivation/impl/src/main/res/values-fa/translations.xml +++ b/features/deactivation/impl/src/main/res/values-fa/translations.xml @@ -1,9 +1,14 @@ + "لطفاً تأیید کنید که می‌خواهید حساب کاربری خود را حذف کنید. این اقدام قابل بازگشت نیست." "حذف همهٔ پیام‌هایم" + "هشدار: کاربران آینده ممکن است مکالمات ناقص را ببینند." + "حذف حساب کاربری شما %1$s است، این کار باعث می‌شود:" "بازگشت‌ناپذیر" + "حساب کاربری خود را %1$s کنید (شما نمی‌توانید دوباره وارد سیستم شوید و شناسه شما قابل استفاده مجدد نیست)." "از کار انداختن دایمی" "برداشتنتان از همهٔ اتاق‌های گپ." "حذف اطّلاعات حسابتان از کارساز هویت." - "غیرفعّال‌سازی حساب" + "پیام‌های شما همچنان برای کاربران ثبت‌نام‌شده قابل مشاهده خواهند بود، اما اگر تصمیم به حذف آنها بگیرید، برای کاربران جدید یا ثبت‌نام‌نشده در دسترس نخواهند بود." + "حذف حساب کاربری" diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 7d23ad0ea1..2cb4a356be 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -10,9 +10,8 @@ package io.element.android.features.enterprise.api import androidx.compose.ui.graphics.Color import io.element.android.compound.colors.SemanticColorsLightDark -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.ElementWellKnown import kotlinx.coroutines.flow.Flow /** @@ -21,9 +20,6 @@ import kotlinx.coroutines.flow.Flow * On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves. */ interface EnterpriseService { - /** Whether this build is an enterprise one; `false` for the public app. */ - val isEnterpriseBuild: Boolean - /** * Whether the given session belongs to an enterprise deployment, which can be true even on a standard build. * @@ -35,23 +31,31 @@ interface EnterpriseService { * Rewrites the authentication server URL when the deployment requires a different one from the one advertised. * * @param url the URL to rewrite. - * @param homeserver the homeserver the URL belongs to. * @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works. */ - suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String + suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String /** - * The homeservers the user is allowed to sign in to; an empty list, or one containing [ANY_ACCOUNT_PROVIDER], means no restriction. + * Returns the list of homeservers the user is allowed to sign in to. + * + * If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver. */ - fun defaultHomeserverList(): List + fun homeserverAllowList(): List /** - * Whether the user is allowed to sign in to a given homeserver, according to [defaultHomeserverList]. + * Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList]. * * @param homeserverUrl the server the user is trying to use. */ suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean + /** + * Whether the given homeserver enforces the use of Element Pro or a derived app. + * + * @param serverName the homeserver to check. + */ + suspend fun isElementProEnforced(serverName: String): Boolean + /** * Override the brand color. * @param sessionId the session to override the brand color for, or null to set the brand color to use when there is no session. @@ -94,26 +98,13 @@ interface EnterpriseService { */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? - /** - * Gets the overridden Element Well-Known data if it has been set, or null if not set. - */ - fun overriddenElementWellKnown(): ElementWellKnown? - - /** - * Gets the Element Server Suite (ESS) config endpoint URL for the given domain. - * Returns `null` when this build does not read its configuration from an ESS deployment. - * - * @param domain the server whose configuration endpoint is requested. - */ - fun essConfigEndpointUrl(domain: String): String? - companion object { const val ANY_ACCOUNT_PROVIDER = "*" } } fun EnterpriseService.canConnectToAnyHomeserver(): Boolean { - return defaultHomeserverList().let { + return homeserverAllowList().let { it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER) } } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt new file mode 100644 index 0000000000..83591ea7bd --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/IsEnterpriseBuild.kt @@ -0,0 +1,18 @@ +/* + * 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.enterprise.api + +/** + * A use case to check if this build of the app is an enterprise build. + */ +fun interface IsEnterpriseBuild { + /** + * Returns `true` if this build of the app is an enterprise build, `false` otherwise. + */ + operator fun invoke(): Boolean +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt index 84067ce56f..94f455f2ff 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -14,6 +14,9 @@ package io.element.android.features.enterprise.api * [init] must have run before the other members return meaningful answers. */ interface SessionEnterpriseService { + /** Loads the deployment configuration of this session; to be called once when the session starts. */ + suspend fun init() + /** Whether Element Call may be used on this session, which a deployment can turn off. */ suspend fun isElementCallAvailable(): Boolean @@ -26,7 +29,4 @@ interface SessionEnterpriseService { /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean - - /** Loads the deployment configuration of this session; to be called once when the session starts. */ - suspend fun init() } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt new file mode 100644 index 0000000000..9bfd3c940a --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/CustomMapTilerConfigProvider.kt @@ -0,0 +1,15 @@ +/* + * 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.enterprise.api.remoteconfig + +/** + * A provider of a runtime-fetched MapTiler configuration, which can be used to override the default MapTiler configuration. + */ +fun interface CustomMapTilerConfigProvider { + suspend fun get(): Result +} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt similarity index 57% rename from libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt rename to features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt index 88a89e7a49..3e8b8b14e1 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/MapTilerConfig.kt @@ -1,29 +1,18 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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. */ -package io.element.android.libraries.wellknown.api +package io.element.android.features.enterprise.api.remoteconfig -data class ElementWellKnown( - val registrationHelperUrl: String?, - val enforceElementPro: Boolean?, - val rageshakeUrl: String?, - val brandColor: String?, - val notificationSound: String?, - val identityProviderAppScheme: String?, - val customRecoveryPassphrase: CustomRecoveryPassphrase?, - val contentScannerUrl: String?, - val forceDisableE2EE: Boolean?, - val mapTilerConfig: MapTilerConfig?, -) +import androidx.compose.runtime.Immutable /** * A `MapTiler` configuration received from the server. This configuration is used to display maps in the app. */ +@Immutable data class MapTilerConfig( /** The API key to use with MapTiler. This is always not null, but can be empty/blank. */ val apiKey: String, diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt deleted file mode 100644 index c9adf21eec..0000000000 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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.enterprise.api.remoteconfig - -import io.element.android.libraries.wellknown.api.ElementWellKnown - -/** - * Typealias for the well-known configuration used for enterprise features. - * - * This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module. - */ -typealias RemoteEnterpriseConfig = ElementWellKnown diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt deleted file mode 100644 index 4507b1a72a..0000000000 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.enterprise.api.remoteconfig - -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -/** - * Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source. - */ -interface RemoteEnterpriseConfigProvider { - /** - * Returns the configuration of a session, served from the cache when possible. - * A stale entry is reported as outdated while a refresh runs in the background, so this rarely waits on the network. - * - * @param sessionId the session whose configuration is requested. - */ - suspend fun get(sessionId: SessionId): WellknownRetrieverResult - - /** - * Creates a provider, which needs a fetcher because the configuration is read over HTTP. - */ - interface Factory { - /** - * @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works. - */ - fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider - } -} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt new file mode 100644 index 0000000000..ace0bada0b --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigResult.kt @@ -0,0 +1,46 @@ +/* + * 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.enterprise.api.remoteconfig + +sealed interface RemoteEnterpriseConfigResult { + /** + * Well-known data has been successfully retrieved. + */ + data class Success(val data: T) : RemoteEnterpriseConfigResult + + /** + * Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched. + */ + data class Outdated(val data: T) : RemoteEnterpriseConfigResult + + /** + * Well-known data is not found (file does not exist server side, we got a 404). `needsRefresh` indicates whether a refresh must be done + * to replace this value: if it's `true`, the caller must trigger a refresh to try to fetch the data again; + * if it's `false`, the caller can assume that the data is not available and no refresh is needed before returning the data. + */ + data class NotFound(val needsRefresh: Boolean) : RemoteEnterpriseConfigResult + + /** + * Any other error. + */ + data class Error(val exception: Exception) : RemoteEnterpriseConfigResult + + fun dataOrNull(): T? = when (this) { + is Success -> data + is Outdated -> data + is Error -> null + is NotFound -> null + } + + fun upToDateDataOrNull(): T? = when (this) { + is Success -> data + is Outdated -> null + is Error -> null + is NotFound -> null + } +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index c75a2cb527..bb10550369 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -14,20 +14,18 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.ElementWellKnown import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @ContributesBinding(AppScope::class) class DefaultEnterpriseService : EnterpriseService { - override val isEnterpriseBuild = false - override suspend fun isEnterpriseUser(sessionId: SessionId) = false - override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url - override fun defaultHomeserverList(): List = emptyList() + override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url + override fun homeserverAllowList(): List = emptyList() override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true + override suspend fun isElementProEnforced(serverName: String): Boolean = false override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit @@ -47,8 +45,4 @@ class DefaultEnterpriseService : EnterpriseService { } override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null - - override fun overriddenElementWellKnown(): ElementWellKnown? = null - - override fun essConfigEndpointUrl(domain: String): String? = null } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt new file mode 100644 index 0000000000..73da4d5d03 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultIsEnterpriseBuild.kt @@ -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.enterprise.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.IsEnterpriseBuild + +@ContributesBinding(AppScope::class) +class DefaultIsEnterpriseBuild : IsEnterpriseBuild { + override fun invoke(): Boolean = false +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt new file mode 100644 index 0000000000..2b136d3739 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultCustomMapTilerConfigProvider.kt @@ -0,0 +1,18 @@ +/* + * 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.enterprise.impl.remoteconfig + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultCustomMapTilerConfigProvider : CustomMapTilerConfigProvider { + override suspend fun get(): Result = Result.success(null) +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt deleted file mode 100644 index fbc5cd5827..0000000000 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.enterprise.impl.remoteconfig - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -@ContributesBinding(AppScope::class) -class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider { - override suspend fun get(sessionId: SessionId): WellknownRetrieverResult { - return WellknownRetrieverResult.NotFound - } - - @ContributesBinding(AppScope::class) - class Factory : RemoteEnterpriseConfigProvider.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider { - return DefaultRemoteEnterpriseConfigProvider() - } - } -} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt index 4d4a800de9..378e4570d9 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt @@ -9,7 +9,7 @@ package io.element.android.features.enterprise.impl.scanner import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider @@ -22,7 +22,7 @@ class DefaultContentScannerUrlProvider : ContentScannerUrlProvider { @ContributesBinding(AppScope::class) class Factory : ContentScannerUrlProvider.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider { + override fun create(urlContentFetcher: ClientUrlContentFetcher): ContentScannerUrlProvider { return DefaultContentScannerUrlProvider() } } diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt index 2bee3f8b4d..474990c6d9 100644 --- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -19,15 +19,9 @@ import org.junit.Test class DefaultEnterpriseServiceTest { @Test - fun `isEnterpriseBuild is false`() { + fun homeserverWhitelist() { val defaultEnterpriseService = DefaultEnterpriseService() - assertThat(defaultEnterpriseService.isEnterpriseBuild).isFalse() - } - - @Test - fun `defaultHomeserverList should return empty list`() { - val defaultEnterpriseService = DefaultEnterpriseService() - assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty() + assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty() } @Test diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 858c46e3bf..a8efcb5873 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -12,9 +12,8 @@ import androidx.compose.ui.graphics.Color import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.Flow @@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class FakeEnterpriseService( - override val isEnterpriseBuild: Boolean = false, private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val defaultHomeserverListResult: () -> List = { emptyList() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, @@ -32,9 +30,8 @@ class FakeEnterpriseService( private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, - private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() }, - private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() }, - private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() }, + private val tweakMasUrlResult: (String, ClientUrlContentFetcher) -> String = { _, _ -> lambdaError() }, + private val isElementProEnforcedResult: (String) -> Boolean = { lambdaError() }, ) : EnterpriseService { private val brandColorState = MutableStateFlow(initialBrandColor) private val semanticColorsState = MutableStateFlow(initialSemanticColors) @@ -43,11 +40,11 @@ class FakeEnterpriseService( isEnterpriseUserResult(sessionId) } - override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask { - tweakMasUrlResult(url, homeserver, urlContentFetcher) + override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String = simulateLongTask { + tweakMasUrlResult(url, urlContentFetcher) } - override fun defaultHomeserverList(): List { + override fun homeserverAllowList(): List { return defaultHomeserverListResult() } @@ -55,6 +52,10 @@ class FakeEnterpriseService( isAllowedToConnectToHomeserverResult(homeserverUrl) } + override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask { + isElementProEnforcedResult(serverName) + } + override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask { overrideBrandColorResult(sessionId, brandColor) } @@ -83,12 +84,4 @@ class FakeEnterpriseService( override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { return getNoisyNotificationChannelIdResult(sessionId) } - - override fun overriddenElementWellKnown(): ElementWellKnown? { - return overrideWellKnownResult() - } - - override fun essConfigEndpointUrl(domain: String): String { - return essConfigEndpointUrlResult(domain) - } } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt deleted file mode 100644 index 2c57dbb530..0000000000 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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.enterprise.test.remoteconfig - -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -class FakeRemoteEnterpriseConfigProvider( - private val getResult: (SessionId) -> WellknownRetrieverResult = { WellknownRetrieverResult.NotFound } -) : RemoteEnterpriseConfigProvider { - override suspend fun get(sessionId: SessionId): WellknownRetrieverResult { - return getResult(sessionId) - } -} diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt deleted file mode 100644 index a49bbba279..0000000000 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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.enterprise.test.remoteconfig - -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.libraries.matrix.api.UrlContentFetcher - -class FakeRemoteEnterpriseConfigProviderFactory( - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), -) : RemoteEnterpriseConfigProvider.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider -} diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt new file mode 100644 index 0000000000..fd429eada5 --- /dev/null +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/Fixtures.kt @@ -0,0 +1,22 @@ +/* + * 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.enterprise.test.remoteconfig + +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig + +fun aMapTilerConfig( + apiKey: String = "test_api_key", + baseUrl: String? = null, + lightStyleId: String? = null, + darkStyleId: String? = null, +) = MapTilerConfig( + apiKey = apiKey, + baseUrl = baseUrl, + lightStyleId = lightStyleId, + darkStyleId = darkStyleId, +) diff --git a/features/ftue/impl/src/main/res/values-ar/translations.xml b/features/ftue/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..afd974d4bb --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,15 @@ + + + "لا يمكنك التأكيد؟" + "إنشاء مفتاح استرداد جديد" + "اختر طريقة التحقق لإعداد المراسلة الآمنة." + "تأكيد هويتك الرقمية" + "استخدام جهاز آخر" + "استخدام مفتاح الاسترداد" + "يمكنك الآن قراءة الرسائل أو إرسالها بأمان، ويمكن لأي شخص تدردش معه أن يثق بهذا الجهاز أيضًا." + "تم التحقق من الجهاز" + "استخدام جهاز آخر" + "في انتظار الجهاز الآخر…" + "يمكنك تغيير إعداداتك لاحقاً." + "السماح بالإشعارات وعدم تفويت أي رسالة" + diff --git a/features/ftue/impl/src/main/res/values-es/translations.xml b/features/ftue/impl/src/main/res/values-es/translations.xml index 8f49a77e0d..9b232f8dab 100644 --- a/features/ftue/impl/src/main/res/values-es/translations.xml +++ b/features/ftue/impl/src/main/res/values-es/translations.xml @@ -2,8 +2,8 @@ "¿No puedes confirmar?" "Crear una nueva clave de recuperación" - "Verifica este dispositivo para configurar la mensajería segura." - "Confirma que eres tú" + "Elige cómo verificar para configurar la mensajería segura." + "Confirma tu identidad digital" "Usar otro dispositivo" "Usar clave de recuperación" "Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo." diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 00f285e3f4..b7b05cca87 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -269,6 +269,11 @@ class HomeFlowNode( parentNode = this, buildContext = buildContext, inviteData = navTarget.inviteData, + callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() { + backstack.pop() + } + }, ) } is NavTarget.SelectNewOwnersWhenLeavingRoom -> { diff --git a/features/home/impl/src/main/res/values-ar/translations.xml b/features/home/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..59f4d6c821 --- /dev/null +++ b/features/home/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,56 @@ + + + "عطّل تحسين البطارية لهذا التطبيق للتأكد من استلام جميع الإشعارات." + "تعطيل التحسين" + "هل لا تصلك الإشعارات؟" + "تم تحديث صوت إشعاراتك، ليصبح أوضح وأسرع وأقل إزعاجًا." + "حدّثنا الأصوات لديك" + "يتم نسخ دردشاتك احتياطيًا تلقائيًا بتشفير من طرف إلى طرف. لاستعادة هذه النسخة الاحتياطية والاحتفاظ بهويتك الرقمية إذا فقدت الوصول إلى جميع أجهزتك، ستحتاج إلى مفتاح الاسترداد." + "الحصول على مفتاح الاسترداد" + "احتفِظ بنسخة احتياطية من دردشاتك" + "أكّد مفتاح الاسترداد الخاص بك للحفاظ على الوصول إلى تخزين المفاتيح وسجل الرسائل." + "أدخل مفتاح الاسترداد" + "هل نسيت مفتاح الاسترداد؟" + "تخزين المفاتيح لديك غير متزامن" + "للتأكد من عدم تفويت أي مكالمة مهمة، يُرجى تغيير إعداداتك للسماح بإشعارات ملء الشاشة عندما يكون هاتفك مقفلاً." + "حسّن تجربة مكالماتك" + "المحادثات" + "المساحات" + "أمتأكد من رغبتك برفض دعوة للانضمام إلى %1$s؟" + "رفض الدعوة" + "أمتأكد من رغبتك برفض المحادثة الخاصة مع %1$s؟" + "رفض المحادثة" + "لا دعوات" + "دعاك %1$s (%2$s)" + "هذه العملية تُجرى لمرة واحدة، شكراً على انتظارك." + "إعداد حسابك" + "إنشاء محادثة جديد أو غرفة" + "مسح عوامل التصفية" + "ابدأ الآن بمراسلة من تشاء." + "لا محادثات بعد." + "المفضلة" + "يمكنك إضافة محادثة إلى المفضلة من إعدادات المحادثة. +يمكنك الآن إلغاء تحديد عوامل التصفية لرؤية محادثاتك الأخرى" + "ليست لديك أي محادثات مفضلة بعد" + "الدعوات" + "ليس لديك أي دعوات قيد الانتظار." + "منخفضة الأولوية" + "ليست لديك أي دردشات منخفضة الأولوية بعد" + "يمكنك إلغاء تحديد عوامل التصفية لرؤية محادثاتك الأخرى" + "ليست لديك محادثات لهذا الاختيار" + "الأشخاص" + "ليست لديك أي رسائل مباشرة بعد" + "الغرف" + "لست في أي غرفة بعد" + "غير المقروءة" + "تهانينا! +ليست لديك أي رسائل غير مقروءة!" + "تم إرسال طلب الانضمام" + "المحادثات" + "وضع علامة كمقروء" + "وضع علامة كغير مقروء" + "تمت ترقية هذه الغرفة" + "مساحاتك" + "يبدو أنك تستخدم جهازاً جديداً. تحقق باستخدام جهاز آخر ليصبح لك وصول إلى رسائلك المشفرة." + "أثبت هويتك" + diff --git a/features/home/impl/src/main/res/values-es/translations.xml b/features/home/impl/src/main/res/values-es/translations.xml index ed6cfdc990..e841d6ebf8 100644 --- a/features/home/impl/src/main/res/values-es/translations.xml +++ b/features/home/impl/src/main/res/values-es/translations.xml @@ -3,9 +3,11 @@ "Deshabilita la optimización de la batería en esta aplicación para asegurarte de que recibes notificaciones." "Deshabilitar la optimización" "¿No llegan las notificaciones?" - "Recupera tu identidad criptográfica y tu historial de mensajes con una clave de recuperación si has perdido todos tus dispositivos actuales." - "Configurar la recuperación" - "Configura la recuperación para proteger tu cuenta" + "Se ha actualizado tu sonido de notificación: más claro, rápido y menos disruptivo." + "Hemos actualizado tus sonidos" + "Tus chats se respaldan automáticamente con cifrado de extremo a extremo. Para restaurar esta copia de seguridad y conservar tu identidad digital si pierdes el acceso a todos tus dispositivos, necesitarás tu clave de recuperación." + "Obtener clave de recuperación" + "Copia de seguridad de tus chats" "Confirma tu clave de recuperación para mantener el acceso a tu almacén de claves y al historial de mensajes." "Introduce tu clave de recuperación" "¿Olvidaste tu clave de recuperación?" @@ -13,6 +15,7 @@ "Para asegurarte de que nunca te pierdas una llamada importante, modifica tus ajustes para permitir notificaciones a pantalla completa cuando el teléfono esté bloqueado." "Mejora tu experiencia de llamada" "Chats" + "Espacios" "¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?" "Rechazar la invitación" "¿Estás seguro de que quieres rechazar este chat privado con %1$s?" @@ -32,6 +35,7 @@ Por ahora, puedes deseleccionar los filtros para ver tus otros chats" "Invitaciones" "No tienes ninguna invitación pendiente." "Prioridad baja" + "Aún no tienes ningún chat de prioridad baja" "Puedes deseleccionar filtros para ver tus otros chats." "No tienes chats para esta selección" "Personas" @@ -46,6 +50,7 @@ Por ahora, puedes deseleccionar los filtros para ver tus otros chats" "Marcar como leído" "Marcar como no leído" "Esta sala ha sido renovada" + "Tus espacios" "Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados." "Verifica que eres tú" diff --git a/features/home/impl/src/main/res/values-fa/translations.xml b/features/home/impl/src/main/res/values-fa/translations.xml index b0c6a30bc4..64ce86ddc7 100644 --- a/features/home/impl/src/main/res/values-fa/translations.xml +++ b/features/home/impl/src/main/res/values-fa/translations.xml @@ -3,12 +3,16 @@ "از کار انداختن بهینه‌سازی باتری برای این کاره برای اطمینان از گرفتن همهٔ آگاهی‌ها." "از کار انداختن بهینه سازی" "آگاهی‌ها نمی‌رسند؟" - "بازگردانی تاریخچهٔ پیام‌ها و هویت رمزنگاشته‌تان با کلید بازیابی در صورت از دست دادن همهٔ افزاره‌های موجودتان." + "پینگ اعلان شما به‌روزرسانی شده است—واضح‌تر، سریع‌تر و کم‌دردسرتر." + "ما صداهای شما را تازه کرده‌ایم" + "چت‌های شما به طور خودکار با رمزگذاری سراسری پشتیبان‌گیری می‌شوند. برای بازیابی این پشتیبان‌گیری و حفظ هویت دیجیتال خود در زمانی که دسترسی به تمام دستگاه‌هایتان را از دست می‌دهید، به کلید بازیابی خود نیاز خواهید داشت." "دریافت کلید بازیابی" - "برپایی بازیابی" + "از چت‌هایتان پشتیبان بگیرید" "کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیام‌هایتان حفظ شود ." "ورود کلید بازیابیتان" + "کلید بازیابی خود را فراموش کرده‌اید؟" "ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده" + "برای اطمینان از اینکه هرگز تماس مهمی را از دست نمی‌دهید، لطفاً تنظیمات خود را تغییر دهید تا وقتی تلفنتان قفل است، اعلان‌ها به صورت تمام صفحه نمایش داده شوند." "بهبود تجریهٔ تماستان" "گپ‌ها" "فضاها" @@ -30,6 +34,7 @@ "دعوت‌ها" "هیچ دعوت منتظری ندارید." "اولویت کم" + "شما هنوز هیچ چت با اولویت پایین ندارید" "می توانید پالایه‌ها را برای دیدن دیگر گپ‌هایتان بردارید" "هیچ گپی برای این گزینش ندارید" "افراد" @@ -44,6 +49,7 @@ "علامت‌گذاری به عنوان خوانده شده" "نشان به ناخوانده" "این اتاق ارتقا یافته" + "فضاهای شما" "گویا از افزاره‌ای جدید استفاده می‌کنید. تأیید با افزاره‌ای دیگر برای دسترسی به پیام‌های رمزنگاری شده‌تان." "تأیید کنید که خودتانید" diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 8eee8e1ee2..5e8570adec 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -11,6 +11,7 @@ package io.element.android.features.home.impl import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.home.api.HomeEntryPoint +import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -36,7 +37,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, directLogoutView = { _ -> lambdaError() }, reportRoomEntryPoint = { _, _, _ -> lambdaError() }, - declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, + declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, leaveRoomRenderer = { _, _, _ -> lambdaError() }, sessionCoroutineScope = backgroundScope, diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt index 4174d99222..3b9d512d80 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt @@ -10,13 +10,19 @@ package io.element.android.features.invite.api.declineandblock import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import io.element.android.features.invite.api.InviteData import io.element.android.libraries.architecture.FeatureEntryPoint -fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { +interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { fun createNode( parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: Callback, ): Node + + interface Callback : Plugin { + fun onDeclineSuccess() + } } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt index 2fe5c23088..89a2aaf238 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt @@ -17,7 +17,9 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope @@ -31,6 +33,7 @@ class DeclineAndBlockNode( data class Inputs(val inviteData: InviteData) : NodeInputs private val inviteData = inputs().inviteData + private val callback: DeclineInviteAndBlockEntryPoint.Callback = callback() private val presenter = presenterFactory.create(inviteData) @Composable @@ -39,6 +42,7 @@ class DeclineAndBlockNode( DeclineAndBlockView( state = state, onBackClick = ::navigateUp, + onDeclineSuccess = callback::onDeclineSuccess, modifier = modifier ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt index 1d14df024a..841034516e 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun DeclineAndBlockView( state: DeclineAndBlockState, onBackClick: () -> Unit, + onDeclineSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -53,7 +54,7 @@ fun DeclineAndBlockView( val isDeclining = state.declineAction is AsyncAction.Loading AsyncActionView( async = state.declineAction, - onSuccess = { onBackClick() }, + onSuccess = { onDeclineSuccess() }, errorMessage = { stringResource(CommonStrings.error_unknown) }, onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) }, onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) } @@ -149,5 +150,6 @@ internal fun DeclineAndBlockViewPreview( DeclineAndBlockView( state = state, onBackClick = {}, + onDeclineSuccess = {}, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt index d6e93fb805..4a5734af45 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt @@ -22,8 +22,9 @@ class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: DeclineInviteAndBlockEntryPoint.Callback, ): Node { val inputs = DeclineAndBlockNode.Inputs(inviteData) - return parentNode.createNode(buildContext, plugins = listOf(inputs)) + return parentNode.createNode(buildContext, plugins = listOf(inputs, callback)) } } diff --git a/features/invite/impl/src/main/res/values-ar/translations.xml b/features/invite/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..31ed141c3e --- /dev/null +++ b/features/invite/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,18 @@ + + + "لن ترى أي رسائل أو دعوات إلى غرف من هذا المستخدم" + "حظر المستخدم" + "أبلغ موفّر حسابك عن هذه الغرفة." + "صِف سبب الإبلاغ…" + "رفض وحظر" + "أمتأكد من رغبتك برفض دعوة للانضمام إلى %1$s؟" + "رفض الدعوة" + "أمتأكد من رغبتك برفض المحادثة الخاصة مع %1$s؟" + "رفض المحادثة" + "لا دعوات" + "دعاك %1$s (%2$s)" + "نعم، ارفض واحظر" + "هل أنت متأكد من رغبتك في رفض الدعوة للانضمام إلى هذه الغرفة؟ سيمنع هذا أيضًا %1$s من التواصل معك أو دعوتك إلى غرف." + "رفض الدعوة والحظر" + "رفض وحظر" + diff --git a/features/invite/impl/src/main/res/values-fa/translations.xml b/features/invite/impl/src/main/res/values-fa/translations.xml index 9cbad94f91..9e245d7e9f 100644 --- a/features/invite/impl/src/main/res/values-fa/translations.xml +++ b/features/invite/impl/src/main/res/values-fa/translations.xml @@ -1,6 +1,8 @@ + "شما هیچ پیام یا دعوتنامه‌ای از این کاربر نخواهید دید." "انسداد کاربر" + "این اتاق را به ارائه دهنده حساب خود گزارش دهید." "شرح دلیل گزارش…" "رد و انسداد" "مطمئنید که می‌خواهید دعوت پیوستن به %1$s را رد کنید؟" @@ -10,6 +12,7 @@ "بدون دعوت" "%1$s (%2$s) دعوتتان کرد" "بله. رد و انسداد" + "آیا مطمئنید که می‌خواهید دعوت به عضویت در این اتاق را رد کنید؟ این کار همچنین مانع از تماس %1$s با شما یا دعوت شما به اتاق‌ها می‌شود." "رد دعوت و انسداد" "رد و انسداد" diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt index 55c31a09d2..5cfb091e82 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.invite.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder @@ -41,6 +42,20 @@ class DeclineAndBlockViewTest : RobolectricTest() { } } + @Test + fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setDeclineAndBlockView( + aDeclineAndBlockState( + declineAction = AsyncAction.Success(Unit), + eventSink = eventsRecorder, + ), + onDeclineSuccess = it, + ) + } + } + @Test fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() { private fun AndroidComposeUiTest.setDeclineAndBlockView( state: DeclineAndBlockState, onBackClick: () -> Unit = EnsureNeverCalled(), + onDeclineSuccess: () -> Unit = EnsureNeverCalled(), ) { setContent { DeclineAndBlockView( state = state, onBackClick = onBackClick, + onDeclineSuccess = onDeclineSuccess, ) } } diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt index 13bb13dccf..f09cda2337 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt @@ -11,7 +11,9 @@ package io.element.android.features.invite.impl.declineandblock import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.invite.test.anInviteData +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode import org.junit.Rule import org.junit.Test @@ -31,12 +33,17 @@ class DefaultDeclineAndBlockEntryPointTest { ) } val inviteData = anInviteData() + val callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() = lambdaError() + } val result = entryPoint.createNode( parentNode = parentNode, buildContext = BuildContext.root(null), - inviteData = inviteData + inviteData = inviteData, + callback = callback, ) assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java) assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData)) + assertThat(result.plugins).contains(callback) } } diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt index 52d4d17790..19625f1473 100644 --- a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt +++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt @@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { parentNode: Node, buildContext: BuildContext, inviteData: InviteData, + callback: DeclineInviteAndBlockEntryPoint.Callback, ): Node { lambdaError() } diff --git a/features/invitepeople/impl/src/main/res/values-ar/translations.xml b/features/invitepeople/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..fa28cb13b9 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,9 @@ + + + "أنت عضو بالفعل" + "مَدعُوّ بالفعل" + "ليست لديك حاليًا أي دردشات مع جهات الاتصال هذه. أكّد دعوتهم إلى هذه الغرفة قبل المتابعة." + "ليست لديك حاليًا أي دردشات مع جهة الاتصال هذه. أكّد دعوتها إلى هذه الغرفة قبل المتابعة." + "هل تريد دعوة جهات اتصال جديدة إلى هذه الغرفة؟" + "هل تريد دعوة جهة اتصال جديدة إلى هذه الغرفة؟" + diff --git a/features/invitepeople/impl/src/main/res/values-es/translations.xml b/features/invitepeople/impl/src/main/res/values-es/translations.xml index e62bb211e5..b9058446b4 100644 --- a/features/invitepeople/impl/src/main/res/values-es/translations.xml +++ b/features/invitepeople/impl/src/main/res/values-es/translations.xml @@ -1,5 +1,9 @@ - "Ya eres miembro" + "Ya es miembro" "Ya estás invitado" + "De momento no tienes ningún chat con estos contactos. Confirma que quieres invitarlos a esta sala antes de continuar." + "De momento no tienes ningún chat con este contacto. Confirma que quieres invitarlo a esta sala antes de continuar." + "¿Invitar contactos nuevos a esta sala?" + "¿Invitar contacto nuevo a esta sala?" diff --git a/features/invitepeople/impl/src/main/res/values-fa/translations.xml b/features/invitepeople/impl/src/main/res/values-fa/translations.xml index 544f01b8c8..100abe539c 100644 --- a/features/invitepeople/impl/src/main/res/values-fa/translations.xml +++ b/features/invitepeople/impl/src/main/res/values-fa/translations.xml @@ -2,4 +2,8 @@ "از پیش عضو است" "از پیش دعوت شده" + "شما در حال حاضر هیچ گفت‌وگویی با این مخاطبین ندارید. قبل از ادامه، دعوت آنها را به این اتاق تأیید کنید." + "شما در حال حاضر هیچ گفت‌وگویی با این مخاطب ندارید. قبل از ادامه، دعوت او را به این اتاق تأیید کنید." + "مخاطبین جدید را به این اتاق دعوت کنید؟" + "مخاطب جدید را به این اتاق دعوت می‌کنید؟" diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt index 93f8f53412..493c180731 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt @@ -69,6 +69,9 @@ class JoinRoomFlowNode( parentNode = this, buildContext = buildContext, inviteData = navTarget.inviteData, + callback = object : DeclineInviteAndBlockEntryPoint.Callback { + override fun onDeclineSuccess() = navigateUp() + }, ) NavTarget.Root -> rootNode(buildContext) } @@ -99,7 +102,7 @@ class JoinRoomFlowNode( acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, onAcceptInviteSuccess = {}, - onDeclineInviteSuccess = {}, + onDeclineInviteSuccess = { navigateUp() }, modifier = Modifier ) } diff --git a/features/joinroom/impl/src/main/res/values-ar/translations.xml b/features/joinroom/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..84a81ad6a5 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,34 @@ + + + "لقد حظرك %1$s." + "تم حظرك" + "السبب: %1$s." + "إلغاء الطلب" + "نعم، ألغِ" + "هل أنت متأكد من رغبتك في إلغاء طلبك للانضمام إلى هذه الغرفة؟" + "إلغاء طلب الانضمام" + "نعم، ارفض واحظر" + "هل أنت متأكد من رغبتك في رفض الدعوة للانضمام إلى هذه الغرفة؟ سيمنع هذا أيضًا %1$s من التواصل معك أو دعوتك إلى غرف." + "رفض الدعوة والحظر" + "رفض وحظر" + "فشل الانضمام" + "إما أنك تحتاج إلى دعوة للانضمام، أو قد توجد قيود على الوصول." + "نسيان" + "تحتاج إلى دعوة للانضمام" + "بدعوة من" + "إنضمام" + "قد تحتاج إلى دعوة أو إلى أن تكون عضوًا في مساحة لتتمكن من الانضمام." + "إرسال طلب الانضمام" + "الأحرف المسموح بها: %1$d من %2$d" + "رسالة (اختيارية)" + "ستتلقى دعوة للانضمام إلى الغرفة إذا تم قبول طلبك." + "تم إرسال طلب الانضمام" + "تعذّر عرض معاينة الغرفة. قد يكون ذلك بسبب مشكلات في الشبكة أو الخادم." + "تعذّر عرض معاينة هذه الغرفة" + "لا يدعم %1$s المساحات بعد. يمكنك الوصول إلى المساحات عبر الويب." + "المساحات غير مدعومة بعد" + "انقر على الزر أدناه وسيتم إشعار مسؤول الغرفة. ستتمكن من الانضمام إلى المحادثة بعد الموافقة." + "يجب أن تكون عضوًا في هذه الغرفة لعرض سجل الرسائل." + "هل تريد الانضمام إلى هذه الغرفة؟" + "المعاينة غير متاحة" + diff --git a/features/joinroom/impl/src/main/res/values-es/translations.xml b/features/joinroom/impl/src/main/res/values-es/translations.xml index 698a6eb1c9..6773318776 100644 --- a/features/joinroom/impl/src/main/res/values-es/translations.xml +++ b/features/joinroom/impl/src/main/res/values-es/translations.xml @@ -1,7 +1,7 @@ - "Has sido baneado por %1$s." - "Has sido baneado." + "Has sido vetado por %1$s." + "Has sido vetado" "Motivo: %1$s." "Cancelar solicitud" "Sí, cancelar" @@ -15,9 +15,11 @@ "O es necesario estar invitado para unirse o puede haber restricciones de acceso." "Olvidar" "Necesitas una invitación para unirte" + "Te invitó" "Unirse" "Es posible que necesites ser invitado o ser miembro de un espacio para poder unirte." "Enviar solicitud de unión" + "%1$d de %2$d caracteres permitidos" "Mensaje (opcional)" "Recibirás una invitación para unirte a la sala si se acepta tu solicitud." "Solicitud de unión enviada" diff --git a/features/joinroom/impl/src/main/res/values-fa/translations.xml b/features/joinroom/impl/src/main/res/values-fa/translations.xml index 3f9fc99877..d5102b939f 100644 --- a/features/joinroom/impl/src/main/res/values-fa/translations.xml +++ b/features/joinroom/impl/src/main/res/values-fa/translations.xml @@ -1,23 +1,30 @@ - "به دست %1$s از این اتاق محروم شدید." - "از این اتاق محروم شدید" + "شما توسط %1$s مسدود شده‌اید." + "شما محروم شدید" "دلیل: %1$s." "لغو درخواست" "بله. لغو" + "آیا مطمئن هستید که می‌خواهید درخواست عضویت خود در این اتاق را لغو کنید؟" "لغو درخواست پیوستن" "بله. رد و انسداد" + "آیا مطمئنید که می‌خواهید دعوت به عضویت در این اتاق را رد کنید؟ این کار همچنین مانع از تماس %1$s با شما یا دعوت شما به اتاق‌ها می‌شود." "رد دعوت و انسداد" "رد و انسداد" "پیوستن شکست خورد" - "فراموشی این اتاق" - "برای پیوستن به این اتاق نیاز به دعوت دارید" + "یا باید دعوتنامه داشته باشید یا ممکن است محدودیت‌هایی برای دسترسی وجود داشته باشد." + "فراموش کردن" + "برای عضویت نیاز به دعوتنامه دارید" "دعوت شده از سوی" "پیوستن" "برای پیوستن به فضا باید دعوت شده باشید." "در زدن برای پیوستن" + "کاراکترهای مجاز %1$d از %2$d" "پیام (اختیاری)" + "در صورت پذیرش درخواست، دعوتنامه‌ای برای ورود به اتاق دریافت خواهید کرد." "درخواست پیوستن فرستاده شد" + "ما نتوانستیم پیش‌نمایش اتاق را نمایش دهیم. این ممکن است به دلیل مشکلات شبکه یا سرور باشد." + "ما نتوانستیم پیش‌نمایش این اتاق را نمایش دهیم" "%1$s هنوز از فضاها پشتیبانی نمی‌کند. می‌توانید روی وب به فضاها دسترسی داشته باشید." "فضاها هنوز پشتیبانی نمی‌شوند" "زدن روی این دکمه برای آگاه شدن مدیر اتاق. پس از تأیید می‌توانید به گفت‌وگو بپیوندید." diff --git a/features/knockrequests/impl/src/main/res/values-ar/translations.xml b/features/knockrequests/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..4ff4995748 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,40 @@ + + + "نعم، قبول الكل" + "هل أنت متأكد من رغبتك في قبول جميع طلبات الانضمام؟" + "قبول جميع الطلبات" + "قبول الكل" + "تعذّر قبول جميع الطلبات. هل ترغب في المحاولة مرة أخرى؟" + "فشل قبول جميع الطلبات" + "جارٍ قبول جميع طلبات الانضمام" + "تعذّر قبول هذا الطلب. هل ترغب في المحاولة مرة أخرى؟" + "فشل قبول الطلب" + "جارٍ قبول طلب الانضمام" + "نعم، ارفض واحظر" + "هل أنت متأكد من رغبتك في رفض %1$s وحظره؟ لن يتمكن هذا المستخدم من طلب الوصول للانضمام إلى هذه الغرفة مرة أخرى." + "رفض الوصول والحظر" + "جارٍ رفض الوصول وحظره" + "نعم، ارفض" + "هل أنت متأكد من رغبتك في رفض طلب %1$s للانضمام إلى هذه الغرفة؟" + "رفض الوصول" + "رفض وحظر" + "تعذّر رفض هذا الطلب. هل ترغب في المحاولة مرة أخرى؟" + "فشل رفض الطلب" + "جارٍ رفض طلب الانضمام" + "عندما يطلب أحدهم الانضمام إلى الغرفة، ستتمكن من رؤية طلبه هنا." + "لا توجد طلبات انضمام معلّقة" + "جارٍ تحميل طلبات الانضمام…" + "طلبات الانضمام" + + "%1$s يريد الانضمام إلى هذه الغرفة" + "%1$s +%2$d آخر يريدان الانضمام إلى هذه الغرفة" + "%1$s +%2$d آخران يريدون الانضمام إلى هذه الغرفة" + "%1$s +%2$d آخرون يريدون الانضمام إلى هذه الغرفة" + "%1$s +%2$d آخرين يريدون الانضمام إلى هذه الغرفة" + "%1$s +%2$d آخرين يريدون الانضمام إلى هذه الغرفة" + + "عرض الكل" + "قبول" + "%1$s يريد الانضمام إلى هذه الغرفة" + "عرض" + diff --git a/features/knockrequests/impl/src/main/res/values-fa/translations.xml b/features/knockrequests/impl/src/main/res/values-fa/translations.xml index 5b931e4f00..af786de6de 100644 --- a/features/knockrequests/impl/src/main/res/values-fa/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-fa/translations.xml @@ -1,21 +1,34 @@ "بله. پذیرش همه" + "آیا مطمئن هستید که می‌خواهید همه درخواست‌های عضویت را بپذیرید؟" "پذیرش همهٔ درخواست‌ها" "پذیرش همه" + "ما نتوانستیم همه درخواست‌ها را بپذیریم. آیا مایلید دوباره امتحان کنید؟" "شکست در پذیرش همهٔ درخواست‌ها" "پذیرفتن همهٔ درخواست‌های پیوستن" + "ما نتوانستیم این درخواست را بپذیریم. آیا مایلید دوباره امتحان کنید؟" "شکست در پذیرش درخواست" "پذیرفتن درخواست پیوستن" "بله. رد و انسداد" + "آیا مطمئن هستید که می‌خواهید %1$s را رد و مسدود کنید؟ این کاربر دیگر نمی‌تواند درخواست دسترسی برای پیوستن به این اتاق را بدهد." "رد و تحریم دسترسی" "رد کردن و تحریم دسترسی" "بله. رد شود" + "آیا مطمئن هستید که می‌خواهید درخواست %1$s برای پیوستن به این اتاق را رد کنید؟" "رد کردن دسترسی" "رد و تحریم" + "ما نمی‌توانستیم این درخواست را رد کنیم. آیا مایلید دوباره امتحان کنید؟" "شکتس در رد درخواست" "در کردن درخواست پیوستن" + "وقتی کسی درخواست عضویت در اتاق را می‌دهد، می‌توانید درخواست او را اینجا ببینید." + "درخواست عضویت در حال بررسی نیست" + "در حال بارگیری درخواست‌های عضویت…" "درخواست‌های پیوستن" + + "%1$s +%2$d افراد دیگری هم می‌خواهند به این اتاق بپیوندند" + "%1$s +%2$d دیگران می‌خواهند به این اتاق بپیوندند" + "دیدن همه" "پذیرش" "%1$s می‌خواهد به این اتاق بپیوندد" diff --git a/features/leaveroom/api/src/main/res/values-ar/translations.xml b/features/leaveroom/api/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..9253cb5365 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ar/translations.xml @@ -0,0 +1,10 @@ + + + "هل أنت متأكد من رغبتك في مغادرة هذه المحادثة؟ هذه المحادثة ليست عامة ولن تتمكن من الانضمام إليها مرة أخرى دون دعوة." + "أأنت متأكد من رغبتك بخروج الغرفة؟ أنت الشخص الوحيد هنا. وإذا ما غادرت فلن يعود أحد قادراً على الدخول في المستقبل، بما فيهم أنت." + "أأنت متأكد من رغبتك بخروج الغرفة؟ هذه الغرفة ليست عامة ولن تعود قادراً على الدخول مجدداً بلا دعوة" + "اختيار المالكين" + "أنت المالك الوحيد لهذه الغرفة. يجب أن تنقل الملكية إلى شخص آخر قبل مغادرة الغرفة." + "نقل الملكية" + "أأنت متأكد من رغبتك بخروج الغرفة؟" + diff --git a/features/leaveroom/api/src/main/res/values-es/translations.xml b/features/leaveroom/api/src/main/res/values-es/translations.xml index 81b169e5c9..59457c8dad 100644 --- a/features/leaveroom/api/src/main/res/values-es/translations.xml +++ b/features/leaveroom/api/src/main/res/values-es/translations.xml @@ -3,5 +3,8 @@ "¿Estás seguro de que quieres salir de esta conversación? Esta conversación no es pública y no podrás volver a unirte sin una invitación." "¿Estás seguro de que quieres salir de esta sala? Eres la única persona aquí. Si te vas, nadie podrá unirse en el futuro, ni siquiera tú." "¿Seguro que quieres salir de esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación." + "Elegir propietarios" + "Eres el único propietario de esta sala. Debes transferir la propiedad a otra persona antes de salir de la sala." + "Transferir la propiedad" "¿Seguro que quieres salir de la sala?" diff --git a/features/leaveroom/api/src/main/res/values-fa/translations.xml b/features/leaveroom/api/src/main/res/values-fa/translations.xml index ec2cd89314..6391a70210 100644 --- a/features/leaveroom/api/src/main/res/values-fa/translations.xml +++ b/features/leaveroom/api/src/main/res/values-fa/translations.xml @@ -4,6 +4,7 @@ "مطمئنید که می‌خواهید این اتاق را ترک کنید؟ تنها فرد این‌جا هستید. در صورت ترک، هیچ‌کسی از جمله خودتان در آینده نخواهد توانست به آن بپیوندد." "مطمئنید که می‌خواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید." "گزینش مالکان" + "شما تنها مالک این اتاق هستید. قبل از ترک اتاق، باید مالکیت را به شخص دیگری منتقل کنید." "انتقال مالکیت" "مطمئنید که می‌خواهید این اتاق را ترک کنید؟" diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt index d11dc7e7e8..e2065bca1f 100644 --- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt @@ -60,7 +60,13 @@ class LeaveRoomPresenter( roomId: RoomId, leaveAction: MutableState>, ) = launch(dispatchers.io) { - client.getRoom(roomId)?.use { room -> + val room = client.getRoom(roomId) + if (room == null) { + Timber.e("Unable to find room $roomId to leave it") + leaveAction.value = AsyncAction.Failure(RoomNotFoundException(roomId)) + return@launch + } + room.use { val roomInfo = room.roomInfoFlow.first() leaveAction.value = when { roomInfo.isDm -> Confirmation.Dm(roomId) @@ -78,7 +84,8 @@ class LeaveRoomPresenter( leaveAction: MutableState>, ) = launch(dispatchers.io) { leaveAction.runCatchingUpdatingState { - client.getRoom(roomId)!!.use { room -> + val room = client.getRoom(roomId) ?: throw RoomNotFoundException(roomId) + room.use { room .leave() .onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) } @@ -100,3 +107,5 @@ class LeaveRoomPresenter( } } } + +internal class RoomNotFoundException(roomId: RoomId) : IllegalStateException("Room $roomId not found") diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt index 90b7f2369f..f8f7a771e1 100644 --- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt @@ -175,6 +175,32 @@ class LeaveBaseRoomPresenterTest { } } + @Test + fun `present - show error if the room to confirm leaving is not found`() = runTest { + val presenter = createLeaveRoomPresenter(client = FakeMatrixClient()) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + val errorState = awaitItem() + assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show error if the room to leave is not found`() = runTest { + val presenter = createLeaveRoomPresenter(client = FakeMatrixClient()) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false)) + val progressState = awaitItem() + assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading) + val errorState = awaitItem() + assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - reset state after error`() = runTest { val presenter = createLeaveRoomPresenter( diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt index 7afb9a7623..bff81a3e9e 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt @@ -39,6 +39,10 @@ class LinkNewDesktopHandler( LinkDesktopStep.Uninitialized ) + /** + * A [StateFlow] that emits the current step of the link desktop process. + * It starts with [LinkDesktopStep.Uninitialized] and will emit other steps as they occur. + */ val stepFlow: StateFlow get() = linkDesktopStepFlow.asStateFlow() @@ -53,15 +57,31 @@ class LinkNewDesktopHandler( timerJob = null } - fun createNewHandler() { - resetJobs() - handler = matrixClient.createLinkDesktopHandler().getOrNull() + /** + * Starts (or restarts) a timer that will emit an error in [stepFlow] after 2 minutes. + */ + fun startTimer() { + timerJob?.cancel() timerJob = sessionScope.launch { delay(2.minutes) linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long."))) } } + /** + * Creates a new [LinkDesktopHandler] and cancels any existing job. + * This should be called when the user wants to retry scanning a QR code. + */ + fun createNewHandler() { + currentJob?.cancel() + currentJob = null + handler = matrixClient.createLinkDesktopHandler().getOrNull() + } + + /** + * Resets the state of the handler and cancels any existing job. + * This should be called when the user wants to start over. + */ fun reset() { resetJobs() sessionScope.launch { @@ -69,6 +89,12 @@ class LinkNewDesktopHandler( } } + /** + * Handles the scanned QR code data. + * This should be called when the user scans a QR code. + * + * @param data The scanned QR code data as a byte array. + */ fun onScannedCode(data: ByteArray) { resetJobs() val currentHandler = handler diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index afa570f0a9..b31aca8cb2 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -263,6 +263,8 @@ class LinkNewDeviceFlowNode( } LinkDeviceType.Desktop -> { linkNewDesktopHandler.reset() + // Ensure unlock state does not last longer than the timeout for scanning the QR code, so start the timer after unlock + linkNewDesktopHandler.startTimer() backstack.push(NavTarget.DesktopNotice) } } @@ -278,10 +280,14 @@ class LinkNewDeviceFlowNode( NavTarget.DesktopNotice -> { val callback = object : DesktopNoticeNode.Callback { override fun navigateBack() { + // Stop the timer when the user leaves the screen (user will have to authenticate again) + linkNewDesktopHandler.reset() backstack.pop() } override fun navigateToQrCodeScanner() { + // Start again the timer when the user enters the next screen (they clicked on "Ready to scan") + linkNewDesktopHandler.startTimer() backstack.push(NavTarget.DesktopScanQrCode) } } @@ -290,6 +296,8 @@ class LinkNewDeviceFlowNode( NavTarget.DesktopScanQrCode -> { val callback = object : ScanQrCodeNode.Callback { override fun cancel() { + // start again the timer when the user goes back to the DesktopNoticeNode + linkNewDesktopHandler.startTimer() backstack.pop() } } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt index 39c9c6fef3..9436c0e901 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt @@ -40,9 +40,19 @@ class LinkNewMobileHandler( LinkMobileStep.Uninitialized ) + /** + * A [StateFlow] that emits the current step of the link mobile process. + * It starts with [LinkMobileStep.Uninitialized] and will emit other steps as they occur. + */ val stepFlow: StateFlow get() = linkMobileStepFlow.asStateFlow() + /** + * Creates a new [LinkMobileHandler] and starts the linking process. + * If [forRotating] is true, it indicates that this is a rotation of the QR code. + * + * @param forRotating Whether this is a rotation of the QR code. Defaults to false. + */ fun createAndStartNewHandler(forRotating: Boolean = false) { Timber.tag(loggerTag.value).d("createAndStartNewHandler()") currentJob?.cancel() @@ -62,6 +72,10 @@ class LinkNewMobileHandler( } } + /** + * Resets the handler and cancels any ongoing job. + * This will also emit [LinkMobileStep.Uninitialized] to the [stepFlow]. + */ fun reset() { currentJob?.cancel() currentJob = null @@ -70,10 +84,18 @@ class LinkNewMobileHandler( } } + /** + * Rotates the QR code by creating a new handler and starting the linking process. + * This is typically called when a timeout occurs for the current QR code. + */ fun rotateQrCode() { createAndStartNewHandler(forRotating = true) } + /** + * Handles the case where there are too many QR code rotations. + * This will reset the handler and emit an error to the [stepFlow]. + */ fun onTooManyRotation() { reset() sessionScope.launch { diff --git a/features/linknewdevice/impl/src/main/res/values-ar/translations.xml b/features/linknewdevice/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..895a0e7efe --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,60 @@ + + + "امسح رمز QR ضوئيًا" + "افتح %1$s على كمبيوتر محمول أو مكتبي" + "امسح رمز QR ضوئيًا باستخدام هذا الجهاز" + "جاهز للمسح الضوئي" + "افتح %1$s على كمبيوتر مكتبي للحصول على رمز QR" + "الأرقام غير متطابقة" + "أدخل الرمز المكوّن من رقمين" + "سيؤكد هذا أن الاتصال بجهازك الآخر آمن." + "أدخل الرقم المعروض على جهازك الآخر" + "لا يدعم موفّر حسابك %1$s." + "%1$s غير مدعوم" + "لا يدعم موفّر حسابك تسجيل الدخول إلى جهاز جديد باستخدام رمز QR." + "رمز QR غير مدعوم" + "تم إلغاء تسجيل الدخول على الجهاز الآخر." + "تم إلغاء طلب تسجيل الدخول" + "انتهت صلاحية تسجيل الدخول. يرجى المحاولة مرة أخرى." + "لم يكتمل تسجيل الدخول في الوقت المحدد" + "افتح %1$s على الجهاز الآخر" + "اختر %1$s" + "«تسجيل الدخول باستخدام رمز QR»" + "امسح رمز QR المعروض هنا باستخدام الجهاز الآخر" + "افتح %1$s على الجهاز الآخر" + "كمبيوتر مكتبي" + "جارٍ تحميل رمز QR…" + "جهاز محمول" + "ما نوع الجهاز الذي تريد ربطه؟" + "يرجى المحاولة مرة أخرى والتأكد من إدخال الرمز المكوّن من رقمين بشكل صحيح. إذا ظلت الأرقام غير متطابقة، فتواصل مع موفّر حسابك." + "الأرقام غير متطابقة" + "تعذّر إنشاء اتصال آمن بالجهاز الجديد. لا تزال أجهزتك الحالية آمنة ولا داعي للقلق بشأنها." + "ماذا الآن؟" + "حاول تسجيل الدخول مجددًا باستخدام رمز QR إذا كانت هذه مشكلة في الشبكة." + "إذا واجهت المشكلة نفسها، جرّب شبكة Wi-Fi مختلفة أو استخدم بيانات الهاتف المحمول بدلًا من Wi-Fi." + "إذا لم ينجح ذلك، سجّل الدخول يدويًا" + "الاتصال غير آمن" + "سيُطلب منك إدخال الرقمين الظاهرين على هذا الجهاز." + "أدخل الرقم أدناه على جهازك الآخر" + "تم إلغاء تسجيل الدخول على الجهاز الآخر." + "تم إلغاء طلب تسجيل الدخول" + "تم رفض تسجيل الدخول على الجهاز الآخر." + "تم رفض تسجيل الدخول" + "لست بحاجة إلى فعل أي شيء آخر." + "جهازك الآخر مسجّل الدخول بالفعل" + "انتهت صلاحية تسجيل الدخول. يرجى المحاولة مرة أخرى." + "لم يكتمل تسجيل الدخول في الوقت المحدد" + "جهازك الآخر لا يدعم تسجيل الدخول إلى %s باستخدام رمز QR. + +جرّب تسجيل الدخول يدويًا، أو امسح رمز QR ضوئيًا باستخدام جهاز آخر." + "رمز QR غير مدعوم" + "لا يدعم موفّر حسابك %1$s." + "%1$s غير مدعوم" + "استخدم رمز QR المعروض على الجهاز الآخر." + "حاول مرة أخرى" + "رمز QR غير صحيح" + "تحتاج إلى منح %1$s إذنًا لاستخدام كاميرا جهازك للمتابعة." + "اسمح بالوصول إلى الكاميرا لمسح رمز QR ضوئيًا" + "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى." + "في انتظار جهازك الآخر" + diff --git a/features/linknewdevice/impl/src/main/res/values-es/translations.xml b/features/linknewdevice/impl/src/main/res/values-es/translations.xml index c33dc3ad88..5593f4bfd6 100644 --- a/features/linknewdevice/impl/src/main/res/values-es/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-es/translations.xml @@ -1,16 +1,34 @@ "Escanea el código QR" + "Abre %1$s en un ordenador" "Escanea el código QR con este dispositivo" "Listo para escanear" + "Abre %1$s en un ordenador para obtener el código QR" + "Solo escanea un código QR después de haber seguido los pasos anteriores en tu propio dispositivo. Nunca escanees un código que te haya enviado otra persona." + "Los números no coinciden" + "Introduce el código de 2 dígitos" + "Esto verificará que la conexión con tu otro dispositivo sea segura." + "Introduce el número que aparece en tu otro dispositivo" "Tu proveedor de cuentas no es compatible con %1$s." "%1$s no admitido" + "El proveedor de tu cuenta no admite el inicio de sesión en un nuevo dispositivo con un código QR." "Código QR no admitido" "El inicio de sesión se canceló en el otro dispositivo." "Solicitud de inicio de sesión cancelada" "El inicio de sesión ha caducado. Inténtalo de nuevo." "El inicio de sesión no se completó a tiempo" + "Abre %1$s en el otro dispositivo" "Selecciona %1$s" + "«Iniciar sesión con un código QR»" + "Escanea el código QR que se muestra aquí con el otro dispositivo" + "Abre %1$s en el otro dispositivo" + "Ordenador" + "Cargando código QR…" + "Dispositivo móvil" + "¿Qué tipo de dispositivo quieres vincular?" + "Inténtalo de nuevo y asegúrate de haber introducido correctamente el código de 2 dígitos. Si los números siguen sin coincidir, ponte en contacto con el proveedor de tu cuenta." + "Los números no coinciden" "No se pudo establecer una conexión segura con el nuevo dispositivo. Tus dispositivos actuales siguen siendo seguros y no tienes que preocuparte por ellos." "¿Y ahora qué?" "Intenta iniciar sesión de nuevo con un código QR en caso de que se trate de un problema de red" @@ -23,6 +41,8 @@ "Solicitud de inicio de sesión cancelada" "El inicio de sesión se rechazó en el otro dispositivo." "Inicio de sesión rechazado" + "No tienes que hacer nada más." + "Tu otro dispositivo ya está conectado" "El inicio de sesión ha caducado. Inténtalo de nuevo." "El inicio de sesión no se completó a tiempo" "Tu otro dispositivo no admite el inicio de sesión en %s con un código QR. diff --git a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml index f7b0d7f3db..20c064410c 100644 --- a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml @@ -1,16 +1,34 @@ "پویش کد پاس" + "%1$s را روی لپ‌تاپ یا رایانه رومیزی باز کنید." "پویش کد پاس با این افزاره" "آمادهٔ پویش" + "برای دریافت کد QR، %1$s را روی رایانه رومیزی باز کنید." + "فقط پس از انجام مراحل بالا در دستگاه خود، کد QR را اسکن کنید. هرگز کدی را که توسط شخص دیگری ارسال شده است، اسکن نکنید." + "اعداد مطابقت ندارند" + "کد دو رقمی را وارد کنید" + "این کار تأیید می‌کند که اتصال به دستگاه دیگر شما ایمن است." + "عدد نمایش داده شده در دستگاه دیگر خود را وارد کنید" "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمی‌کند." "%1$s پشتیبانی نمی‌شود" + "ارائه دهنده حساب شما از ورود به دستگاه جدید با کد QR پشتیبانی نمی‌کند." "کد پاس پشتیبانی نمی‌شود" "ورود روی افزارهٔ دیگر لغو شد." "درخواست ورد لغو شد" "ورود منقضی شد. لطفاً دوباره تلاش کنید." "ورود در زمان معیّن کامل نشد" + "%1$s را در دستگاه دیگر باز کنید" "گزینش %1$s" + "«ورود با کد QR»" + "کد QR نشان داده شده در اینجا را با دستگاه دیگر اسکن کنید" + "%1$s را در دستگاه دیگر باز کنید" + "کامپیوتر رومیزی" + "در حال بارگذاری کد QR…" + "دستگاه همراه" + "چه نوع دستگاهی را می‌خواهید لینک کنید؟" + "لطفاً دوباره امتحان کنید و مطمئن شوید که کد دو رقمی را به درستی وارد کرده‌اید. اگر اعداد هنوز مطابقت ندارند، با ارائه دهنده حساب خود تماس بگیرید." + "اعداد مطابقت ندارند" "نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزاره‌های موجودتان هنوز امنند و نیازی نیست نگرانشان باشید." "اکنون چه؟" "آزمودن ورود دوباره با کد پاس در صورت مشکل شبکه" @@ -23,6 +41,8 @@ "درخواست ورد لغو شد" "ورود به دست افزارهٔ دیگر رد شد." "ورود رد شد" + "لازم نیست کار دیگری انجام دهید." + "دستگاه دیگر شما قبلاً وارد سیستم شده است" "ورود منقضی شد. لطفاً دوباره تلاش کنید." "ورود در زمان معیّن کامل نشد" "افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمی‌کند. diff --git a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml index 0ba5a30e58..c18b9bbfd0 100644 --- a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml @@ -5,6 +5,7 @@ "Skannaa QR-koodi tällä laitteella" "Valmis skannaamaan" "Avaa %1$s pöytätietokoneella saadaksesi QR-koodin" + "Skannaa QR-koodi vasta, kun olet noudattanut yllä olevia ohjeita omalla laitteellasi. Älä koskaan skannaa toisen henkilön lähettämää koodia." "Numerot eivät täsmää" "Kirjoita 2-numeroinen koodi" "Tämä varmistaa, että yhteys toiseen laitteeseesi on turvallinen." diff --git a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml index 12a770af17..7e642578af 100644 --- a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml @@ -5,6 +5,7 @@ "Scanner le code QR avec cet appareil" "Prêt à scanner" "Ouvrir %1$s sur un ordinateur pour obtenir le code QR" + "Ne scannez un code QR qu’après avoir suivi les étapes ci-dessus sur votre propre appareil. Ne scannez jamais un code envoyé par quelqu’un d’autre." "Les nombres ne correspondent pas" "Saisissez le code à 2 chiffres" "Cela permettra de vérifier que la connexion à votre autre appareil est sécurisée." diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index b3eeacf87f..8830b39a22 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(projects.libraries.wellknown.api) + implementation(projects.features.enterprise.api) implementation(libs.coil.compose) implementation(libs.datetime) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt index a2212119f5..752dd2401c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -8,7 +8,7 @@ package io.element.android.features.location.api import androidx.compose.runtime.staticCompositionLocalOf -import io.element.android.libraries.wellknown.api.MapTilerConfig +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** * Provides a [MapTilerConfig] for the MapLibre maps. diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 33e454722f..b3149964e4 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -37,6 +37,7 @@ import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.SuccessResult import io.element.android.compound.theme.ElementTheme +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.rememberStaticMapBuilder @@ -45,7 +46,6 @@ import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.wellknown.api.MapTilerConfig /** * Shows a static map image downloaded via a third party service's static maps API. diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index 78eae2f633..cd339f67b0 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -8,8 +8,8 @@ package io.element.android.features.location.api.internal +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.BuildConfig -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlin.math.roundToInt /** diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt index 10aef90116..d817a3307f 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -10,8 +10,8 @@ package io.element.android.features.location.api.internal +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.BuildConfig -import io.element.android.libraries.wellknown.api.MapTilerConfig internal class MapTilerTileServerStyleUriBuilder( private val baseUrl: String, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt index 212da08647..31992b1dd6 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -10,8 +10,7 @@ package io.element.android.features.location.api.internal import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import io.element.android.libraries.wellknown.api.MapTilerConfig - +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** * Builds an URL for a 3rd party service provider static maps API. */ diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt index 9676005f59..67936525e5 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -11,7 +11,7 @@ package io.element.android.features.location.api.internal import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.wellknown.api.MapTilerConfig +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** * Builds a style URI for a MapLibre compatible tile server. diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt index 09aae4f310..c153ab1ce4 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -9,7 +9,7 @@ package io.element.android.features.location.api.internal import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.wellknown.api.MapTilerConfig +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerTileServerStyleUriBuilderTest { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index eaa238bed0..84c4ab4fba 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.common.MapDefaults import io.element.android.libraries.architecture.AsyncData @@ -50,7 +51,6 @@ import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.wellknown.api.MapTilerConfig import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapOptions diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index d388e0c5d2..55f1e088d2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -22,7 +22,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults @@ -73,7 +73,7 @@ class ShareLocationPresenter( private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationStore: LiveLocationStore, private val userLocationStateFactory: UserLocationState.Factory, - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, + private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, ) : Presenter { @AssistedFactory fun interface Factory { @@ -96,7 +96,7 @@ class ShareLocationPresenter( val currentUser by client.userProfile.collectAsState() val customMapConfig by produceState(AsyncData.Loading()) { // Ignore errors - value = AsyncData.Success(remoteEnterpriseConfigProvider.get(client.sessionId).dataOrNull()?.mapTilerConfig) + value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) } val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> perms.sendLiveLocationPermissions() diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index d2cce94ef8..b29228f866 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -8,12 +8,12 @@ package io.element.android.features.location.impl.share +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 6d7ff5e612..9214d5a017 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -9,13 +9,13 @@ package io.element.android.features.location.impl.share import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.persistentListOf import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 2d9a4900dd..bbec7b449f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -22,7 +22,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.live.ActiveLiveLocationShareManager @@ -47,7 +47,6 @@ import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.joinedRoomMembers @@ -58,18 +57,18 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch -@AssistedInject class ShowLocationPresenter( +@AssistedInject +class ShowLocationPresenter( @Assisted private val mode: ShowLocationMode, permissionsPresenterFactory: PermissionsPresenter.Factory, private val locationActions: LocationActions, private val buildMeta: BuildMeta, private val dateFormatter: DateFormatter, private val stringProvider: StringProvider, - private val sessionId: SessionId, private val joinedRoom: JoinedRoom, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val userLocationStateFactory: UserLocationState.Factory, - private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, + private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, ) : Presenter { @AssistedFactory fun interface Factory { fun create(mode: ShowLocationMode): ShowLocationPresenter @@ -89,7 +88,7 @@ import kotlinx.coroutines.launch val customMapTilerConfig by produceState(AsyncData.Loading()) { // Ignore errors - value = AsyncData.Success(remoteEnterpriseConfigProvider.get(sessionId).dataOrNull()?.mapTilerConfig) + value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) } fun checkLocationConstraints() { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 2ae76ef9e9..0472eb94e9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -8,6 +8,7 @@ package io.element.android.features.location.impl.show +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData @@ -17,7 +18,6 @@ import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index a1f349d644..bf582eb59a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState @@ -18,7 +19,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType -import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.toImmutableList class ShowLocationStateProvider : PreviewParameterProvider { diff --git a/features/location/impl/src/main/res/values-ar/translations.xml b/features/location/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..fb70505fbe --- /dev/null +++ b/features/location/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,6 @@ + + + "سيُخزَّن سجل موقعك المباشر في الغرفة وسيكون مرئيًا للأعضاء بعد انتهاء الجلسة." + "اختر مدة مشاركة موقعك المباشر." + "ليست لديك صلاحيات لمشاركة موقعك المباشر في هذه الغرفة" + diff --git a/features/location/impl/src/main/res/values-es/translations.xml b/features/location/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..9fc15d3468 --- /dev/null +++ b/features/location/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,6 @@ + + + "Tu historial de ubicación en tiempo real se guardará en la sala y será visible para los miembros después de que termine la sesión." + "Elige por cuánto tiempo quieres compartir tu ubicación en tiempo real" + "No tienes permiso para compartir tu ubicación en tiempo real en esta sala." + diff --git a/features/location/impl/src/main/res/values-fa/translations.xml b/features/location/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000000..5183ed2f43 --- /dev/null +++ b/features/location/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,6 @@ + + + "سابقه موقعیت مکانی زنده شما در اتاق ذخیره می‌شود و پس از پایان جلسه برای اعضا قابل مشاهده خواهد بود." + "مدت زمان اشتراک‌گذاری موقعیت مکانی زنده خود را انتخاب کنید." + "شما مجوز اشتراک‌گذاری موقعیت مکانی زنده خود را در این اتاق ندارید" + diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index 9be168e5a2..a37011e1d9 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -11,7 +11,6 @@ package io.element.android.features.location.impl.share import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.impl.common.FakeUserLocationStateFactory import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter @@ -59,7 +58,7 @@ class DefaultShareLocationEntryPointTest { sessionId = room.sessionId, ), userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider = { Result.success(null) }, ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 6748c5a675..d249fdaf69 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -19,8 +19,8 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider -import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig +import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory @@ -32,8 +32,6 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.test.FakeMessageComposerContext -import io.element.android.features.wellknown.test.aMapTilerConfig -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -55,7 +53,6 @@ import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermiss import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.assert @@ -92,7 +89,7 @@ class ShareLocationPresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), client: FakeMatrixClient = fakeMatrixClient, - remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider: () -> Result = { Result.success(null) }, ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, @@ -106,7 +103,7 @@ class ShareLocationPresenterTest { liveLocationShareManager = liveLocationShareManager, liveLocationStore = liveLocationStore, userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider, + customMapTilerConfigProvider = customMapTilerConfigProvider, ) @Test @@ -134,9 +131,7 @@ class ShareLocationPresenterTest { client = FakeMatrixClient( sessionId = A_USER_ID, ), - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( - getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) } - ) + customMapTilerConfigProvider = { Result.success(mapTilerConfig) } ) shareLocationPresenter.test { val state = awaitItem() diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index 69451eedb7..db5c6b9f9f 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -11,7 +11,6 @@ package io.element.android.features.location.impl.show import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationMode @@ -21,7 +20,6 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.services.analytics.test.FakeAnalyticsService @@ -51,10 +49,9 @@ class DefaultShowLocationEntryPointTest { dateFormatter = FakeDateFormatter(), stringProvider = FakeStringProvider(), joinedRoom = joinedRoom, - sessionId = A_SESSION_ID, liveLocationShareManager = FakeActiveLiveLocationShareManager(), userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider = { Result.success(null) }, ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 77b26eb66c..7d9b0d5e03 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -12,7 +12,8 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState @@ -23,20 +24,15 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.test.FakeActiveLiveLocationShareManager -import io.element.android.features.wellknown.test.aMapTilerConfig -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.dateformatter.test.FakeDateFormatter -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.LiveLocationShare -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test @@ -69,9 +65,8 @@ class ShowLocationPresenterTest { ), locationActions: FakeLocationActions = fakeLocationActions, joinedRoom: JoinedRoom = FakeJoinedRoom(), - sessionId: SessionId = A_SESSION_ID, liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), - remoteEnterpriseConfigProvider: FakeRemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), + customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) }, ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, @@ -80,21 +75,15 @@ class ShowLocationPresenterTest { dateFormatter = fakeDateFormatter, stringProvider = FakeStringProvider(), joinedRoom = joinedRoom, - sessionId = sessionId, liveLocationShareManager = liveLocationShareManager, userLocationStateFactory = FakeUserLocationStateFactory(), - remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider, + customMapTilerConfigProvider = customMapTilerConfigProvider, ) @Test fun `present - non-null customMapStyleUrl`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") - val shareLocationPresenter = createShowLocationPresenter( - sessionId = A_SESSION_ID, - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( - getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) } - ) - ) + val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) }) shareLocationPresenter.test { val state = awaitItem() assertThat(state.customMapTilerConfig.isLoading()).isTrue() @@ -131,12 +120,7 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - val presenter = createShowLocationPresenter( - sessionId = A_SESSION_ID, - remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( - getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = aMapTilerConfig())) } - ) - ) + val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) }) presenter.test { val initialState = awaitItem() diff --git a/features/lockscreen/impl/src/main/res/values-ar/translations.xml b/features/lockscreen/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..b277c420ea --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,46 @@ + + + "المصادقة بالمقاييس الحيوية" + "إلغاء القفل باستخدام المقاييس الحيوية" + "إلغاء القفل باستخدام المقاييس الحيوية" + "تأكيد المصادقة الحيوية" + "هل نسيت رمز PIN؟" + "تغيير رمز PIN" + "السماح بإلغاء القفل باستخدام المقاييس الحيوية" + "إزالة رمز PIN" + "هل أنت متأكد من رغبتك في إزالة رمز PIN؟" + "إزالة رمز PIN؟" + "السماح بـ %1$s" + "أفضّل استخدام رمز PIN" + "وفّر بعض الوقت واستخدم %1$s لإلغاء قفل التطبيق في كل مرة" + "اختر رمز PIN" + "تأكيد رمز PIN" + "اقفل %1$s لإضافة مزيد من الأمان إلى محادثاتك. + +اختر شيئًا يسهل تذكره. إذا نسيت رمز PIN هذا، فسيتم تسجيل خروجك من التطبيق." + "لا يمكنك اختيار هذا كرمز PIN لأسباب أمنية" + "اختر رمز PIN مختلفًا" + "يُرجى إدخال رمز PIN نفسه مرتين" + "رمزا PIN غير متطابقين" + "ستحتاج إلى تسجيل الدخول مرة أخرى وإنشاء رمز PIN جديد للمتابعة" + "تتم إزالة هذا الجهاز" + + "لديك %1$d محاولات لإلغاء القفل" + "لديك %1$d محاولة لإلغاء القفل" + "لديك %1$d محاولتان لإلغاء القفل" + "لديك %1$d محاولات لإلغاء القفل" + "لديك %1$d محاولة لإلغاء القفل" + "لديك %1$d محاولة لإلغاء القفل" + + + "رمز PIN غير صحيح. لديك %1$d فرص أخرى" + "رمز PIN غير صحيح. لديك %1$d فرصة أخرى" + "رمز PIN غير صحيح. لديك %1$d فرصتان أخريان" + "رمز PIN غير صحيح. لديك %1$d فرص أخرى" + "رمز PIN غير صحيح. لديك %1$d فرصة أخرى" + "رمز PIN غير صحيح. لديك %1$d فرصة أخرى" + + "استخدام المقاييس الحيوية" + "استخدام رمز PIN" + "تجري إزالة الجهاز…" + diff --git a/features/lockscreen/impl/src/main/res/values-es/translations.xml b/features/lockscreen/impl/src/main/res/values-es/translations.xml index c0e34a3763..085c1d1cbf 100644 --- a/features/lockscreen/impl/src/main/res/values-es/translations.xml +++ b/features/lockscreen/impl/src/main/res/values-es/translations.xml @@ -23,7 +23,7 @@ Elige algo que puedas recordar. Si olvidas este PIN, se cerrará la sesión de l "Por favor ingresa el mismo PIN dos veces" "Los PINs no coinciden" "Tendrás que volver a iniciar sesión y crear un nuevo PIN para continuar" - "Se está cerrando tu sesión" + "Se está eliminando este dispositivo" "Tienes %1$d intento de desbloqueo" "Tienes %1$d intentos de desbloqueo" @@ -34,5 +34,5 @@ Elige algo que puedas recordar. Si olvidas este PIN, se cerrará la sesión de l "Usar desbloqueo biométrico" "Usar PIN" - "Cerrando sesión…" + "Eliminando dispositivo…" diff --git a/features/lockscreen/impl/src/main/res/values-pl/translations.xml b/features/lockscreen/impl/src/main/res/values-pl/translations.xml index 29691987f6..e6faf72945 100644 --- a/features/lockscreen/impl/src/main/res/values-pl/translations.xml +++ b/features/lockscreen/impl/src/main/res/values-pl/translations.xml @@ -17,7 +17,7 @@ "Potwierdź PIN" "Zablokuj %1$s, aby zwiększyć bezpieczeństwo swoich czatów. -Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz ten PIN, zostaniesz wylogowany z aplikacji." +Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz tego PINu, zostaniesz wylogowany z aplikacji." "Nie możesz wybrać tego PIN\'u ze względów bezpieczeństwa" "Wybierz inny kod PIN" "Wprowadź ten sam kod PIN dwa razy" diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 86a8e0e7bc..6cf8945e05 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.qrcode) implementation(projects.libraries.oauth.api) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiUtils) implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) @@ -80,6 +81,7 @@ dependencies { testImplementation(projects.features.login.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.preferences.test) + testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index a662f20351..bbfe7c47d8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.replace -import com.bumble.appyx.navmodel.backstack.operation.singleTop import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject @@ -33,13 +32,11 @@ import io.element.android.features.login.api.LoginEntryPoint 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.qrcode.QrCodeLoginFlowNode -import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode import io.element.android.features.login.impl.screens.classic.ClassicFlowNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode -import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.architecture.BackstackView @@ -129,12 +126,6 @@ class LoginFlowNode( @Parcelize data object ChooseAccountProvider : NavTarget - @Parcelize - data object ChangeAccountProvider : NavTarget - - @Parcelize - data object SearchAccountProvider : NavTarget - @Parcelize data class LoginPassword( val initialLogin: String = "", @@ -261,43 +252,9 @@ class LoginFlowNode( override fun navigateToLoginPassword() { backstack.push(NavTarget.LoginPassword()) } - - override fun navigateToChangeAccountProvider() { - backstack.push(NavTarget.ChangeAccountProvider) - } } createNode(buildContext, plugins = listOf(inputs, callback)) } - NavTarget.ChangeAccountProvider -> { - val callback = object : ChangeAccountProviderNode.Callback { - override fun onDone() { - // Go back to the Account Provider screen - val confirmAccountProvider = backstack.elements.value.firstOrNull { - it.key.navTarget is NavTarget.ConfirmAccountProvider - }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) - backstack.singleTop(confirmAccountProvider) - } - - override fun navigateToSearchAccountProvider() { - backstack.push(NavTarget.SearchAccountProvider) - } - } - - createNode(buildContext, plugins = listOf(callback)) - } - NavTarget.SearchAccountProvider -> { - val callback = object : SearchAccountProviderNode.Callback { - override fun onDone() { - // Go back to the Account Provider screen - val confirmAccountProvider = backstack.elements.value.firstOrNull { - it.key.navTarget is NavTarget.ConfirmAccountProvider - }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) - backstack.singleTop(confirmAccountProvider) - } - } - - createNode(buildContext, plugins = listOf(callback)) - } is NavTarget.LoginPassword -> { val inputs = LoginPasswordNode.Inputs( initialLogin = navTarget.initialLogin, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt index 119923c67d..a3a475df2f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt @@ -11,17 +11,14 @@ package io.element.android.features.login.impl.accesscontrol import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.impl.changeserver.AccountProviderAccessException -import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever @ContributesBinding(AppScope::class) class DefaultAccountProviderAccessControl( + private val isEnterpriseBuild: IsEnterpriseBuild, private val enterpriseService: EnterpriseService, - private val wellknownRetrieverFactory: WellknownRetriever.Factory, - private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory, ) : AccountProviderAccessControl { override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { assertIsAllowedToConnectToAccountProvider( @@ -38,29 +35,19 @@ class DefaultAccountProviderAccessControl( title: String, accountProviderUrl: String, ) { - if (enterpriseService.isEnterpriseBuild.not()) { + if (isEnterpriseBuild().not()) { // Ensure that Element Pro is not required for this account provider - val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow() - temporaryMatrixClient.use { - val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient) - val wellKnown = wellknownRetriever.getElementWellKnown( - host = accountProviderUrl, - // It's expected this is hardcoded to the well-known endpoint - source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT, - ).dataOrNull() - - if (wellKnown?.enforceElementPro == true) { - throw AccountProviderAccessException.NeedElementProException( - unauthorisedAccountProviderTitle = title, - applicationId = ELEMENT_PRO_APPLICATION_ID, - ) - } + if (enterpriseService.isElementProEnforced(accountProviderUrl)) { + throw AccountProviderAccessException.NeedElementProException( + unauthorisedAccountProviderTitle = title, + applicationId = ELEMENT_PRO_APPLICATION_ID, + ) } } if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) { throw AccountProviderAccessException.UnauthorizedAccountProviderException( unauthorisedAccountProviderTitle = title, - authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), + authorisedAccountProviderTitles = enterpriseService.homeserverAllowList(), ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt index 931959d4ea..6d85f2c5ff 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -13,27 +13,64 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.canConnectToAnyHomeserver +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch @SingleIn(AppScope::class) @Inject class AccountProviderDataSource( - enterpriseService: EnterpriseService, + private val enterpriseService: EnterpriseService, + private val appPreferencesStore: AppPreferencesStore, + @AppCoroutineScope private val coroutineScope: CoroutineScope, ) { - private val defaultAccountProvider = createAccountProvider( - url = enterpriseService.defaultHomeserverList() + // The provider used when the user has not selected one: an enterprise/MDM-configured provider, + // else matrix.org. The most recently used provider (from history) can override it, see init. + private val configuredAccountProvider = createAccountProvider( + url = enterpriseService.homeserverAllowList() .firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } ?: AuthenticationConfig.MATRIX_ORG_URL ) - private val accountProvider: MutableStateFlow = MutableStateFlow(defaultAccountProvider) + private val accountProvider: MutableStateFlow = MutableStateFlow(configuredAccountProvider) val flow: StateFlow = accountProvider.asStateFlow() + // The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]). + // Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a + // successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip). + var lastSelectedAccountProviderUrl: String? = null + private set + + init { + // Seed the default from the last used provider, unless the user has already selected one. + coroutineScope.launch { + val default = defaultAccountProvider() + accountProvider.update { current -> if (current == configuredAccountProvider) default else current } + } + } + suspend fun reset() { - accountProvider.emit(defaultAccountProvider) + accountProvider.emit(defaultAccountProvider()) + } + + /** + * The provider to default to: the most recently used one from history when the user is free to + * connect to any provider, otherwise the enterprise/MDM-configured provider. + */ + private suspend fun defaultAccountProvider(): AccountProvider { + if (!enterpriseService.canConnectToAnyHomeserver()) { + return configuredAccountProvider + } + val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull() + return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider } suspend fun setUrl(url: String) { @@ -41,6 +78,7 @@ class AccountProviderDataSource( } suspend fun setAccountProvider(data: AccountProvider) { + lastSelectedAccountProviderUrl = data.url accountProvider.emit(data) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt deleted file mode 100644 index 8b9b22022e..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt +++ /dev/null @@ -1,77 +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.accountprovider - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -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.libraries.designsystem.atomic.atoms.RoundedIconAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.HorizontalDivider -import io.element.android.libraries.designsystem.theme.components.Text - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 - */ -@Composable -fun AccountProviderOtherView( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxWidth() - .clickable(role = Role.Button) { onClick() } - ) { - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(vertical = 4.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RoundedIconAtom( - size = RoundedIconAtomSize.Medium, - imageVector = CompoundIcons.Search(), - tint = ElementTheme.colors.iconPrimary, - ) - Text( - modifier = Modifier - .padding(start = 16.dp) - .weight(1f), - text = stringResource(R.string.screen_change_account_provider_other), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun AccountProviderOtherViewPreview() = ElementPreview { - AccountProviderOtherView( - onClick = { }, - ) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt new file mode 100644 index 0000000000..b3b99115cc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistory.kt @@ -0,0 +1,32 @@ +/* + * 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.accountprovider + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.preferences.api.store.AppPreferencesStore + +/** + * Persist the account provider the user just authenticated against, so that it can be offered as + * the default (see [AccountProviderDataSource]) and as an autocomplete suggestion on the next + * sign-in. To be called from the manual sign-in / account creation flows only, on success. + * + * See [AppPreferencesStore.addHomeserverToHistory]. + */ +@Inject +class SaveAccountProviderToHistory( + private val accountProviderDataSource: AccountProviderDataSource, + private val appPreferencesStore: AppPreferencesStore, +) { + suspend operator fun invoke() { + // Persist the provider the user actually selected, not the currently-exposed default: the latter can be + // reset back to the history default while the login flow is torn down across an OAuth round-trip. + val url = accountProviderDataSource.lastSelectedAccountProviderUrl + ?: accountProviderDataSource.flow.value.url + appPreferencesStore.addHomeserverToHistory(url) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt index 79f76d5fb5..b7b04beeb2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -22,6 +22,7 @@ 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.MatrixHomeServerDetails @Inject class ChangeServerPresenter( @@ -32,7 +33,7 @@ class ChangeServerPresenter( ) : Presenter { @Composable override fun present(): ChangeServerState { - val changeServerAction: MutableState> = remember { + val changeServerAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } @@ -59,7 +60,7 @@ class ChangeServerPresenter( private suspend fun changeServer( data: AccountProvider, - changeServerAction: MutableState>, + changeServerAction: MutableState>, ) { suspend { defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( @@ -72,6 +73,8 @@ class ChangeServerPresenter( } // Homeserver is valid, remember user choice accountProviderDataSource.setAccountProvider(data) + // Return the resolved details so the caller can sign in without configuring the homeserver again. + details }.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt index 6d8ce25d75..3b4f4d9dad 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt @@ -9,10 +9,13 @@ package io.element.android.features.login.impl.changeserver import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog data class ChangeServerState( - val changeServerAction: AsyncData, + // On success, carries the resolved homeserver details so the caller can proceed with login without + // configuring (and re-networking) the homeserver a second time. + val changeServerAction: AsyncData, val localNetworkPermissionDialog: LocalNetworkPermissionDialog, val eventSink: (ChangeServerEvents) -> Unit ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt index 183158bceb..dd1102e25e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt @@ -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.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog open class ChangeServerStateProvider : PreviewParameterProvider { @@ -45,7 +46,7 @@ open class ChangeServerStateProvider : PreviewParameterProvider = AsyncData.Uninitialized, + changeServerAction: AsyncData = AsyncData.Uninitialized, localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None, ) = ChangeServerState( changeServerAction = changeServerAction, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt index 73400890d5..945b0bc580 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeEvent.kt @@ -7,12 +7,18 @@ package io.element.android.features.login.impl.login +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails + sealed interface LoginModeEvent { data class Submit( val isAccountCreation: Boolean, val homeserverUrl: String, val resolvedHomeserverUrl: String?, val loginHint: String?, + // When the caller has already configured the homeserver (e.g. the account provider screen validated + // it first), these are its resolved details. When set, login reuses them instead of calling + // setHomeserver again, avoiding a redundant network round-trip. + val preConfiguredDetails: MatrixHomeServerDetails? = null, ) : LoginModeEvent data object ClearError : LoginModeEvent diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt index 466cd210ab..59d156308f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt @@ -13,6 +13,7 @@ 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.accountprovider.SaveAccountProviderToHistory 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 @@ -39,6 +40,7 @@ class LoginModePresenter( private val oAuthActionFlow: OAuthActionFlow, private val authenticationService: MatrixAuthenticationService, private val localNetworkPermissionGate: LocalNetworkPermissionGate, + private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @Composable override fun present(): LoginModeState { @@ -73,26 +75,28 @@ class LoginModePresenter( private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState>) { 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() - ) + // Reuse the details the caller already resolved when it configured the homeserver, so we don't + // run setHomeserver (a network round-trip) again. Otherwise configure the homeserver ourselves. + val matrixHomeServerDetails = request.preConfiguredDetails + ?: 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 } - request.isAccountCreation -> throw AccountCreationNotSupported() - matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin - else -> error("Unsupported authentication flow") + }.getOrThrow() + when { + matrixHomeServerDetails.supportsOAuthLogin -> { + val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login + LoginMode.OAuth( + authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow() + ) } - }.getOrThrow() + request.isAccountCreation -> throw AccountCreationNotSupported() + matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin + else -> error("Unsupported authentication flow") + } }.runCatchingUpdatingState( state = loginMode, errorTransform = { @@ -116,6 +120,7 @@ class LoginModePresenter( .onSuccess { loginMode.value = AsyncData.Uninitialized } .onFailure { loginMode.value = AsyncData.Failure(it) } is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url) + .onSuccess { saveAccountProviderToHistory() } .onFailure { loginMode.value = AsyncData.Failure(it) } } oAuthActionFlow.reset() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt deleted file mode 100644 index d9994deb9a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.resolver - -data class HomeserverData( - // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url - val homeserverUrl: String, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt deleted file mode 100644 index 2700922678..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.resolver - -import dev.zacsweers.metro.Inject -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -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.matrix.api.core.MatrixPatterns -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.util.Collections - -/** - * Resolve homeserver base on search terms. - */ -@Inject -class HomeserverResolver( - private val dispatchers: CoroutineDispatchers, - private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, - private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, -) { - fun resolve(userInput: String): Flow> = flow { - val flowContext = currentCoroutineContext() - val trimmedUserInput = userInput.trim() - val searchTerm = when { - MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty() - trimmedUserInput.startsWith("@") -> return@flow - else -> trimmedUserInput - } - if (searchTerm.length < 4) return@flow - val candidateBase = searchTerm.ensureProtocol().removeSuffix("/") - val list = getUrlCandidates(candidateBase) - val currentList = Collections.synchronizedList(mutableListOf()) - // Run all the requests in parallel - withContext(dispatchers.io) { - list.parallelMap { url -> - // 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 (shouldRequestPermissionOrIsValid) { - currentList.add(HomeserverData(homeserverUrl = url)) - withContext(flowContext) { - emit(currentList.toList()) - } - } - } - } - // If list is empty, and candidateBase is a valid an URL, do not block the user. - // A unsupported homeserver / homeserver not found error will be displayed if the user continues - if (currentList.isEmpty() && candidateBase.isValidUrl()) { - emit(listOf(HomeserverData(homeserverUrl = candidateBase))) - } - } - - private fun getUrlCandidates(data: String): List { - return buildList { - if (data.contains(".")) { - // TLD detected? - } else { - add("$data.org") - add("$data.com") - add("$data.io") - } - // Always try what the user has entered - add(data) - } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt deleted file mode 100644 index 018a25dc89..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.changeaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.features.login.impl.util.openLearnMorePage -import io.element.android.libraries.architecture.callback - -@ContributesNode(AppScope::class) -@AssistedInject -class ChangeAccountProviderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - private val presenter: ChangeAccountProviderPresenter, -) : Node(buildContext, plugins = plugins) { - interface Callback : Plugin { - fun onDone() - fun navigateToSearchAccountProvider() - } - - private val callback: Callback = callback() - - @Composable - override fun View(modifier: Modifier) { - val state = presenter.present() - val context = LocalContext.current - ChangeAccountProviderView( - state = state, - modifier = modifier, - onBackClick = ::navigateUp, - onLearnMoreClick = { openLearnMorePage(context) }, - onSuccess = callback::onDone, - onOtherProviderClick = callback::navigateToSearchAccountProvider, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt deleted file mode 100644 index 41605352ef..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.changeaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import dev.zacsweers.metro.Inject -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.api.canConnectToAnyHomeserver -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.uri.ensureProtocol -import kotlinx.collections.immutable.toImmutableList - -@Inject -class ChangeAccountProviderPresenter( - private val changeServerPresenter: Presenter, - private val enterpriseService: EnterpriseService, -) : Presenter { - @Composable - override fun present(): ChangeAccountProviderState { - val staticAccountProviderList = remember { - enterpriseService.defaultHomeserverList() - .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } - .map { it.ensureProtocol() } - .ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) } - .map { url -> - AccountProvider( - url = url, - subtitle = null, - isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, - isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, - ) - } - .toImmutableList() - } - - val canSearchForAccountProviders = remember { - enterpriseService.canConnectToAnyHomeserver() - } - - val changeServerState = changeServerPresenter.present() - return ChangeAccountProviderState( - accountProviders = staticAccountProviderList, - canSearchForAccountProviders = canSearchForAccountProviders, - changeServerState = changeServerState, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt deleted file mode 100644 index 3068a1cb57..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.changeaccountprovider - -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.changeserver.ChangeServerState -import kotlinx.collections.immutable.ImmutableList - -data class ChangeAccountProviderState( - val accountProviders: ImmutableList, - val canSearchForAccountProviders: Boolean, - val changeServerState: ChangeServerState, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt deleted file mode 100644 index 5cec1961a4..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.changeaccountprovider - -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.changeserver.ChangeServerState -import io.element.android.features.login.impl.changeserver.aChangeServerState -import kotlinx.collections.immutable.toImmutableList - -open class ChangeAccountProviderStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aChangeAccountProviderState(), - aChangeAccountProviderState(canSearchForAccountProviders = false), - // Add other state here - ) -} - -fun aChangeAccountProviderState( - accountProviders: List = listOf( - anAccountProvider() - ), - canSearchForAccountProviders: Boolean = true, - changeServerState: ChangeServerState = aChangeServerState(), -) = ChangeAccountProviderState( - accountProviders = accountProviders.toImmutableList(), - canSearchForAccountProviders = canSearchForAccountProviders, - changeServerState = changeServerState, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt deleted file mode 100644 index 212bab515d..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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. - */ - -@file:OptIn(ExperimentalMaterial3Api::class) - -package io.element.android.features.login.impl.screens.changeaccountprovider - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.accountprovider.AccountProviderOtherView -import io.element.android.features.login.impl.accountprovider.AccountProviderView -import io.element.android.features.login.impl.changeserver.ChangeServerEvents -import io.element.android.features.login.impl.changeserver.ChangeServerView -import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule -import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding -import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 - */ -@Composable -fun ChangeAccountProviderView( - state: ChangeAccountProviderState, - onBackClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onSuccess: () -> Unit, - onOtherProviderClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = {}, - navigationIcon = { BackButton(onClick = onBackClick) } - ) - }, - contentWindowInsets = scaffoldScrollableContentInsets, - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding(padding) - .consumeWindowInsets(padding) - ) { - Column( - modifier = Modifier - .verticalScroll(state = rememberScrollState()) - .padding(lazyColumnContentPadding) - ) { - IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), - iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), - title = stringResource(id = R.string.screen_change_account_provider_title), - subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle), - ) - - state.accountProviders.forEach { item -> - val alteredItem = if (item.isMatrixOrg) { - // Set the subtitle from the resource - item.copy( - subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle), - ) - } else { - item - } - AccountProviderView( - item = alteredItem, - onClick = { - state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem)) - } - ) - } - // Other - if (state.canSearchForAccountProviders) { - AccountProviderOtherView( - onClick = onOtherProviderClick - ) - } - Spacer(Modifier.height(32.dp)) - } - ChangeServerView( - state = state.changeServerState, - onLearnMoreClick = onLearnMoreClick, - onSuccess = onSuccess, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview { - ChangeAccountProviderView( - state = state, - onBackClick = { }, - onLearnMoreClick = { }, - onSuccess = { }, - onOtherProviderClick = { }, - ) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt index a9983a19d1..92c3ff0fff 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -60,7 +60,7 @@ class ChooseAccountProviderPresenter( val staticAccountProviderList = remember { // The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point - enterpriseService.defaultHomeserverList() + enterpriseService.homeserverAllowList() .map { it.ensureProtocol() } .map { url -> AccountProvider( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt index d66606b22c..9093a8121f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -9,6 +9,10 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider sealed interface ConfirmAccountProviderEvents { - data object Continue : ConfirmAccountProviderEvents + data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents + + // Carries the account provider to submit (the current field text, or the accepted autocomplete suggestion), + // captured from the view at click time so it can't lag behind fast input (e.g. keyboard autofill). + data class Continue(val accountProvider: String) : ConfirmAccountProviderEvents data object ClearError : ConfirmAccountProviderEvents } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index 0accaea767..e1a2d85412 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -45,7 +45,6 @@ class ConfirmAccountProviderNode( interface Callback : Plugin { fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) - fun navigateToChangeAccountProvider() } private val callback: Callback = callback() @@ -59,7 +58,6 @@ class ConfirmAccountProviderNode( modifier = modifier, onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, - onChange = callback::navigateToChangeAccountProvider, onLearnMoreClick = { openLearnMorePage(context) }, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index 0111b1e7d7..21bc9077dd 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -9,21 +9,37 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +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.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.ChangeServerState 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 io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.preferences.api.store.AppPreferencesStore @AssistedInject class ConfirmAccountProviderPresenter( @Assisted private val params: Params, private val accountProviderDataSource: AccountProviderDataSource, private val loginModePresenter: Presenter, + private val changeServerPresenter: Presenter, + private val appPreferencesStore: AppPreferencesStore, + private val enterpriseService: EnterpriseService, ) : Presenter { data class Params( val isAccountCreation: Boolean, @@ -38,26 +54,95 @@ class ConfirmAccountProviderPresenter( override fun present(): ConfirmAccountProviderState { val accountProvider by accountProviderDataSource.flow.collectAsState() val loginModeState = loginModePresenter.present() + val changeServerState = changeServerPresenter.present() + val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList()) + + // The account providers offered for autocomplete: previously-used ones first, then the + // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in). + // The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is + // rendered without the https:// scheme (added back at connection time). + val autocompleteCandidates = remember(homeserverHistory) { + (homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL) + .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } + .map { it.withoutScheme() } + .distinct() + } + + // Editable input, seeded from the current (history-defaulted) account provider until the user edits it. + // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org". + var userInput by rememberSaveable { mutableStateOf(null) } + val accountProviderInput = userInput ?: accountProvider.url.withoutScheme() + + // Offer the first candidate that the current input is a (case-insensitive) prefix of. + val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) { + val input = accountProviderInput.trim() + input.takeIf { it.isNotEmpty() } + ?.let { prefix -> + autocompleteCandidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } + } + } + + // The account provider submitted via Continue (carried by the event, so it reflects the exact field + // text at click time); used to actually sign in once validation succeeds. + var submittedAccountProvider by remember { mutableStateOf(null) } + val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider) + + // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up. + // Reuse the details resolved while validating, so login does not configure (and re-network) the homeserver again. + LaunchedEffect(changeServerState.changeServerAction) { + val homeServerDetails = changeServerState.changeServerAction.dataOrNull() + if (homeServerDetails != null) { + loginModeState.eventSink( + LoginModeEvent.Submit( + isAccountCreation = params.isAccountCreation, + homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(), + resolvedHomeserverUrl = null, + loginHint = null, + preConfiguredDetails = homeServerDetails, + ) + ) + } + } fun handleEvent(event: ConfirmAccountProviderEvents) { when (event) { - ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink( - LoginModeEvent.Submit( - isAccountCreation = params.isAccountCreation, - homeserverUrl = accountProvider.url, - resolvedHomeserverUrl = null, - loginHint = null, + is ConfirmAccountProviderEvents.UserInputChanged -> { + userInput = event.accountProvider + } + // Validate (and persist) the chosen account provider before proceeding. This also enforces the + // account-provider access control, which the login submit does not run on its own. + is ConfirmAccountProviderEvents.Continue -> { + // Apply the accepted account provider (any accepted completion) back into the field so it + // renders the full server rather than the typed prefix, and survives the OAuth round-trip. + val accountProvider = event.accountProvider.trim() + userInput = accountProvider + // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works. + val accountProviderUrl = accountProvider.ensureProtocol() + submittedAccountProvider = accountProviderUrl + changeServerState.eventSink( + ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl)) ) - ) - ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError) + } + ConfirmAccountProviderEvents.ClearError -> { + loginModeState.eventSink(LoginModeEvent.ClearError) + changeServerState.eventSink(ChangeServerEvents.ClearError) + } } } return ConfirmAccountProviderState( - accountProvider = accountProvider, + accountProviderInput = accountProviderInput, + accountProviderSuggestion = accountProviderSuggestion, isAccountCreation = params.isAccountCreation, loginModeState = loginModeState, + changeServerState = changeServerState, eventSink = ::handleEvent, ) } } + +/** + * The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed. + * Returns the input unchanged when it has no scheme (e.g. `matrix.org`). + */ +private fun String.withoutScheme(): String = substringAfter("://") diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt index 6e6deb8c2c..bf6b0cb0e7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt @@ -8,17 +8,29 @@ 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.changeserver.ChangeServerState import io.element.android.features.login.impl.login.LoginModeState import io.element.android.libraries.architecture.AsyncData data class ConfirmAccountProviderState( - val accountProvider: AccountProvider, + val accountProviderInput: String, + // The full account provider from history that the current input is a prefix of, offered as inline autocomplete. + val accountProviderSuggestion: String?, val isAccountCreation: Boolean, val loginModeState: LoginModeState, + val changeServerState: ChangeServerState, val eventSink: (ConfirmAccountProviderEvents) -> Unit ) { val submitEnabled: Boolean - get() = accountProvider.url.isNotEmpty() && - (loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading) + get() = accountProviderInput.isNotBlank() && + loginModeState.loginMode is AsyncData.Uninitialized && + changeServerState.changeServerAction is AsyncData.Uninitialized + + val isLoading: Boolean + get() = loginModeState.loginMode is AsyncData.Loading || + changeServerState.changeServerAction is AsyncData.Loading + + val isShowingError: Boolean + get() = loginModeState.loginMode is AsyncData.Failure || + changeServerState.changeServerAction is AsyncData.Failure } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt index f597ad6b54..53fa763c2f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt @@ -9,8 +9,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.changeserver.ChangeServerState +import io.element.android.features.login.impl.changeserver.aChangeServerState 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 @@ -20,6 +20,10 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider get() = sequenceOf( aConfirmAccountProviderState(), + aConfirmAccountProviderState( + accountProviderInput = "element", + accountProviderSuggestion = "element.io", + ), aConfirmAccountProviderState( isAccountCreation = true, ), @@ -31,13 +35,17 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider Unit = {}, ) = ConfirmAccountProviderState( - accountProvider = accountProvider, + accountProviderInput = accountProviderInput, + accountProviderSuggestion = accountProviderSuggestion, isAccountCreation = isAccountCreation, loginModeState = loginModeState, + changeServerState = changeServerState, eventSink = eventSink ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 3702b195a1..c81fd529ef 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -8,50 +8,109 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle 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.login.impl.R +import io.element.android.features.login.impl.changeserver.ChangeServerView 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 import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.TextField 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 +/** + * Account provider entry screen, shared by sign in and account creation. + * Figma (Compound Android Templates): + * - Sign in: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10251 + * - Create account: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10239 + */ @Composable fun ConfirmAccountProviderView( state: ConfirmAccountProviderState, onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, - onChange: () -> Unit, modifier: Modifier = Modifier, ) { - val isLoading by remember(state.loginModeState.loginMode) { - derivedStateOf { - state.loginModeState.loginMode is AsyncData.Loading - } - } val eventSink = state.eventSink + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + var input by textFieldState(stateValue = state.accountProviderInput) + + // Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and + // accepted on Continue / Enter). Only valid while the suggestion still starts with the current input. + val suggestionSuffix = state.accountProviderSuggestion + ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } + ?.substring(input.length) + .orEmpty() + + // The Continue button lives in a footer slot that is not recomposed while the user types, so a direct + // capture of the field text (via ::submit) goes stale and can submit an empty/prefilled value. Reading + // the value through rememberUpdatedState keeps it current regardless of which composition scope reads it. + val accountProviderToSubmit by rememberUpdatedState(input + suggestionSuffix) + + fun submit() { + // Dismiss the keyboard and release focus while the account provider is being validated. + focusManager.clearFocus(force = true) + // Submit the exact field text (plus any accepted suggestion). + eventSink(ConfirmAccountProviderEvents.Continue(accountProviderToSubmit)) + } + + // Once a validation / login error has been dismissed, return focus and the keyboard to the field so + // the user can correct the account provider and retry. + var wasShowingError by remember { mutableStateOf(false) } + LaunchedEffect(state.isShowingError) { + if (wasShowingError && !state.isShowingError) { + focusRequester.requestFocus() + keyboardController?.show() + } + wasShowingError = state.isShowingError + } HeaderFooterPage( modifier = modifier, @@ -61,54 +120,113 @@ fun ConfirmAccountProviderView( iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { - R.string.screen_account_provider_signup_title + CommonStrings.screen_change_server_title_register } else { - R.string.screen_account_provider_signin_title - }, - state.accountProvider.title + CommonStrings.screen_change_server_title_login + } ), - subTitle = stringResource( - id = if (state.isAccountCreation) { - R.string.screen_account_provider_signup_subtitle - } else { - R.string.screen_account_provider_signin_subtitle - }, - ) + subTitle = null, ) }, footer = { ButtonColumnMolecule { Button( text = stringResource(id = CommonStrings.action_continue), - showProgress = isLoading, - onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, - enabled = state.submitEnabled || isLoading, + showProgress = state.isLoading, + onClick = ::submit, + enabled = state.submitEnabled && !state.isLoading, modifier = Modifier .fillMaxWidth() .testTag(TestTags.loginContinue) ) - TextButton( - text = stringResource(id = R.string.screen_account_provider_change), - onClick = onChange, - enabled = !isLoading, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.loginChangeServer) - ) } } ) { - LoginModeView( - loginMode = state.loginModeState.loginMode, - onClearError = { - eventSink(ConfirmAccountProviderEvents.ClearError) + val ghostColor = ElementTheme.colors.textSecondary + val ghostTransformation = remember(suggestionSuffix, ghostColor) { + GhostSuffixVisualTransformation(suggestionSuffix, ghostColor) + } + TextField( + value = input, + onValueChange = { + input = it + eventSink(ConfirmAccountProviderEvents.UserInputChanged(it)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp) + .focusRequester(focusRequester) + .testTag(TestTags.changeServerServer), + label = stringResource(id = CommonStrings.screen_change_server_textfield_header), + placeholder = stringResource(id = CommonStrings.screen_change_server_textfield_placeholder), + supportingText = stringResource( + id = if (state.isAccountCreation) { + CommonStrings.screen_change_server_textfield_footer_register + } else { + CommonStrings.screen_change_server_textfield_footer_login + } + ), + visualTransformation = ghostTransformation, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + if (suggestionSuffix.isNotEmpty()) { + // A completion is showing: accept it into the field and drop focus, dismissing the + // keyboard. This shows the completed server (with no stray mid-text cursor, since the + // field is no longer focused) ready to sign in with Continue. When no completion is + // showing, Done submits directly. + val accepted = input + suggestionSuffix + input = accepted + eventSink(ConfirmAccountProviderEvents.UserInputChanged(accepted)) + focusManager.clearFocus(force = true) + } else { + submit() + } + } + ), + singleLine = true, + trailingIcon = if (input.isNotEmpty()) { + { + Box( + Modifier.clickable( + onClickLabel = stringResource(CommonStrings.action_clear), + role = Role.Button, + ) { + input = "" + eventSink(ConfirmAccountProviderEvents.UserInputChanged("")) + } + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear) + ) + } + } + } else { + null }, - onLearnMoreClick = onLearnMoreClick, - onOAuthDetails = onOAuthDetails, - onNeedLoginPassword = onNeedLoginPassword, ) } + // Renders the account-provider validation errors, progress and permission dialogs. + // The successful validation itself is observed by the presenter, which then proceeds with the login flow. + ChangeServerView( + state = state.changeServerState, + onLearnMoreClick = onLearnMoreClick, + onSuccess = {}, + ) + + LoginModeView( + loginMode = state.loginModeState.loginMode, + onClearError = { eventSink(ConfirmAccountProviderEvents.ClearError) }, + onLearnMoreClick = onLearnMoreClick, + onOAuthDetails = onOAuthDetails, + onNeedLoginPassword = onNeedLoginPassword, + ) + LocalNetworkPermissionDialogView( dialog = state.loginModeState.localNetworkPermissionDialog, onSubmit = { @@ -120,6 +238,29 @@ fun ConfirmAccountProviderView( ) } +/** + * Appends [suffix] (in [color]) after the user's text as a non-editable inline autocomplete hint. + * The underlying field value stays limited to what the user actually typed, so the cursor can never + * enter the suggested region. + */ +private class GhostSuffixVisualTransformation( + private val suffix: String, + private val color: Color, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + if (suffix.isEmpty()) return TransformedText(text, OffsetMapping.Identity) + val transformed = buildAnnotatedString { + append(text) + withStyle(SpanStyle(color = color)) { append(suffix) } + } + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(text.length) + } + return TransformedText(transformed, offsetMapping) + } +} + @PreviewsDayNight @Composable internal fun ConfirmAccountProviderViewPreview( @@ -130,6 +271,5 @@ internal fun ConfirmAccountProviderViewPreview( onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, - onChange = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt index f26f342a42..6e28bd8781 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt @@ -20,6 +20,7 @@ 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.accountprovider.SaveAccountProviderToHistory import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -33,6 +34,7 @@ class LoginPasswordPresenter( private val initialLogin: String, private val authenticationService: MatrixAuthenticationService, private val accountProviderDataSource: AccountProviderDataSource, + private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @AssistedFactory interface Factory { @@ -83,6 +85,7 @@ class LoginPasswordPresenter( loggedInState.value = AsyncData.Loading() authenticationService.login(formState.login.trim(), formState.password) .onSuccess { sessionId -> + saveAccountProviderToHistory() loggedInState.value = AsyncData.Success(sessionId) } .onFailure { failure -> diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 06a1641644..75b6714a62 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -191,6 +192,7 @@ private fun LoginForm( enabled = !isLoading, modifier = Modifier .fillMaxWidth() + .bringIntoViewOnImeVisible() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginEmailUsername) .semantics { @@ -243,6 +245,7 @@ private fun LoginForm( enabled = !isLoading, modifier = Modifier .fillMaxWidth() + .bringIntoViewOnImeVisible() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginPassword) .semantics { @@ -274,10 +277,10 @@ private fun LoginForm( } /** - * Ensure that the string does not contain any new line characters, which can happen when pasting values. + * Ensure that the string does not contain any line separator, which can happen when pasting values. */ private fun String.sanitize(): String { - return replace("\n", "") + return filterNot { it == '\n' || it == '\r' } } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 29e4b1afe5..e9bdb5e1e9 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -60,15 +60,15 @@ class OnBoardingPresenter( override fun present(): OnBoardingState { val localCoroutineScope = rememberCoroutineScope() val forcedAccountProvider = remember { - // If defaultHomeserverList() returns a singleton list, this is the default account provider. + // If homeserverAllowList() returns a singleton list, this is the default account provider. // In this case, the user can sign in using this homeserver, or use QrCode login - enterpriseService.defaultHomeserverList().singleOrNull() + enterpriseService.homeserverAllowList().singleOrNull() } val canConnectToAnyHomeserver = remember { enterpriseService.canConnectToAnyHomeserver() } val mustChooseAccountProvider = remember { - !canConnectToAnyHomeserver && enterpriseService.defaultHomeserverList().size > 1 + !canConnectToAnyHomeserver && enterpriseService.homeserverAllowList().size > 1 } val linkAccountProvider by produceState(initialValue = null) { // Account provider from the link, if allowed by the enterprise service diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt deleted file mode 100644 index 8816de3946..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.searchaccountprovider - -sealed interface SearchAccountProviderEvents { - /** - * The user has typed something, expect to get a list of matching account provider results - * in the state. - */ - data class UserInput(val input: String) : SearchAccountProviderEvents -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt deleted file mode 100644 index ddbcc4c847..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.searchaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedInject -import io.element.android.annotations.ContributesNode -import io.element.android.features.login.impl.util.openLearnMorePage -import io.element.android.libraries.architecture.callback - -@ContributesNode(AppScope::class) -@AssistedInject -class SearchAccountProviderNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - private val presenter: SearchAccountProviderPresenter, -) : Node(buildContext, plugins = plugins) { - interface Callback : Plugin { - fun onDone() - } - - private val callback: Callback = callback() - - @Composable - override fun View(modifier: Modifier) { - val state = presenter.present() - val context = LocalContext.current - SearchAccountProviderView( - state = state, - modifier = modifier, - onBackClick = ::navigateUp, - onLearnMoreClick = { openLearnMorePage(context) }, - onSuccess = callback::onDone, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt deleted file mode 100644 index 2efe4501c6..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.searchaccountprovider - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import dev.zacsweers.metro.Inject -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.features.login.impl.resolver.HomeserverResolver -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.architecture.Presenter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@Inject -class SearchAccountProviderPresenter( - private val homeserverResolver: HomeserverResolver, - private val changeServerPresenter: Presenter, -) : Presenter { - @Composable - override fun present(): SearchAccountProviderState { - var userInput by rememberSaveable { - mutableStateOf("") - } - val changeServerState = changeServerPresenter.present() - - val data: MutableState>> = remember { - mutableStateOf(AsyncData.Uninitialized) - } - - LaunchedEffect(userInput) { - onUserInput(userInput, data) - } - - fun handleEvent(event: SearchAccountProviderEvents) { - when (event) { - is SearchAccountProviderEvents.UserInput -> { - userInput = event.input - } - } - } - - return SearchAccountProviderState( - userInput = userInput, - userInputResult = data.value, - changeServerState = changeServerState, - eventSink = ::handleEvent, - ) - } - - private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch { - data.value = AsyncData.Uninitialized - // Debounce - delay(500) - data.value = AsyncData.Loading() - homeserverResolver.resolve(userInput).collect { - data.value = AsyncData.Success(it) - } - if (data.value !is AsyncData.Success) { - data.value = AsyncData.Uninitialized - } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt deleted file mode 100644 index dc02a997a6..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.searchaccountprovider - -import io.element.android.features.login.impl.changeserver.ChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData - -data class SearchAccountProviderState( - val userInput: String, - val userInputResult: AsyncData>, - val changeServerState: ChangeServerState, - val eventSink: (SearchAccountProviderEvents) -> Unit -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt deleted file mode 100644 index 2a8512c38a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.searchaccountprovider - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.login.impl.changeserver.aChangeServerState -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData - -open class SearchAccountProviderStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aSearchAccountProviderState(), - aSearchAccountProviderState(userInputResult = AsyncData.Success(aHomeserverDataList())), - // Add other state here - ) -} - -fun aSearchAccountProviderState( - userInput: String = "", - userInputResult: AsyncData> = AsyncData.Uninitialized, -) = SearchAccountProviderState( - userInput = userInput, - userInputResult = userInputResult, - changeServerState = aChangeServerState(), - eventSink = {} -) - -fun aHomeserverDataList(): List { - return listOf( - aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL), - aHomeserverData(homeserverUrl = "https://no.sliding.sync"), - aHomeserverData(homeserverUrl = "https://invalid"), - ) -} - -fun aHomeserverData( - homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, -): HomeserverData { - return HomeserverData(homeserverUrl = homeserverUrl) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt deleted file mode 100644 index 8335dde112..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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. - */ - -@file:OptIn(ExperimentalMaterial3Api::class) - -package io.element.android.features.login.impl.screens.searchaccountprovider - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.accountprovider.AccountProvider -import io.element.android.features.login.impl.accountprovider.AccountProviderView -import io.element.android.features.login.impl.changeserver.ChangeServerEvents -import io.element.android.features.login.impl.changeserver.ChangeServerView -import io.element.android.features.login.impl.resolver.HomeserverData -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule -import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.form.textFieldState -import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.TextField -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.testtags.TestTags -import io.element.android.libraries.testtags.testTag -import io.element.android.libraries.ui.strings.CommonStrings - -/** - * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435 - */ -@Composable -fun SearchAccountProviderView( - state: SearchAccountProviderState, - onBackClick: () -> Unit, - onLearnMoreClick: () -> Unit, - onSuccess: () -> Unit, - modifier: Modifier = Modifier, -) { - val eventSink = state.eventSink - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = {}, - navigationIcon = { BackButton(onClick = onBackClick) } - ) - } - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding(padding) - .consumeWindowInsets(padding) - ) { - LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { - item { - IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp), - iconStyle = BigIcon.Style.Default(CompoundIcons.Search()), - title = stringResource(id = R.string.screen_account_provider_form_title), - subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle), - ) - } - item { - // TextInput - var userInputState by textFieldState(stateValue = state.userInput) - val focusManager = LocalFocusManager.current - TextField( - value = userInputState, - // readOnly = isLoading, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .onTabOrEnterKeyFocusNext(focusManager) - .testTag(TestTags.changeServerServer), - onValueChange = { - userInputState = it - eventSink(SearchAccountProviderEvents.UserInput(it)) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { - focusManager.moveFocus(FocusDirection.Down) - }), - singleLine = true, - trailingIcon = if (userInputState.isNotEmpty()) { - { - Box( - Modifier.clickable( - onClickLabel = stringResource(CommonStrings.action_clear), - role = Role.Button, - ) { - userInputState = "" - eventSink(SearchAccountProviderEvents.UserInput("")) - } - ) { - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_clear) - ) - } - } - } else { - null - }, - supportingText = stringResource(id = R.string.screen_account_provider_form_notice), - ) - } - - when (state.userInputResult) { - is AsyncData.Failure -> { - // Ignore errors (let the user type more chars) - } - is AsyncData.Loading -> { - item { - Box( - modifier = Modifier - .fillMaxSize() - ) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) - } - } - } - is AsyncData.Success -> { - items(state.userInputResult.data) { homeserverData -> - val item = homeserverData.toAccountProvider() - AccountProviderView( - item = item, - onClick = { - state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item)) - } - ) - } - } - AsyncData.Uninitialized -> Unit - } - item { - Spacer(Modifier.height(32.dp)) - } - } - ChangeServerView( - state = state.changeServerState, - onLearnMoreClick = onLearnMoreClick, - onSuccess = onSuccess, - ) - } - } -} - -@Composable -private fun HomeserverData.toAccountProvider(): AccountProvider { - val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL - return AccountProvider( - url = homeserverUrl, - subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, - // There is no need to know for other servers right now - isPublic = isMatrixOrg, - isMatrixOrg = isMatrixOrg, - ) -} - -@PreviewsDayNight -@Composable -internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview { - SearchAccountProviderView( - state = state, - onBackClick = {}, - onLearnMoreClick = {}, - onSuccess = {}, - ) -} diff --git a/features/login/impl/src/main/res/values-ar/translations.xml b/features/login/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..181b38ec24 --- /dev/null +++ b/features/login/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,109 @@ + + + "تغيير مزوّد الحسابات" + "عنوان السيرفر" + "أدخِل عبارة بحث أو عنوان نطاق." + "ابحث عن شركة، أو مجتمع، أو سيرفر خاص." + "ابحث عن مزوّد حسابات" + "هذا المكان الذي تقطن فيه رسائلك — تماماً كاستخدامك مزوّد بريد إلكتروني للاحتفاظ برسائل البريد خاصّتك فيه." + "أنت على وشك تسجيل الدخول إلى %s" + "هذا المكان الذي تقطن فيه رسائلك — تماماً كاستخدامك مزوّد بريد إلكتروني للاحتفاظ برسائل البريد خاصّتك فيه." + "أنت على وشك إنشاء حساب في %s" + "إن Matrix.org سيرفر ضخم ومجاني في شبكة Matrix العامّة وُجِد من أجل مراسلات آمنة ولا مركزية، يُدار من قِبل مؤسسة Matrix.org." + "غير ذلك" + "استخدم مزوّد حسابات آخر كسيرفرك الخاص أو حساب عمل." + "تغيير مزوّد الحسابات" + "Google Play" + "تطبيق Element Pro مطلوب على %1$s. يرجى تنزيله من المتجر." + "Element Pro مطلوب" + "لم نستطع الوصول لهذا السيرفر. رجاءً تحقق إذا ما كنت أدخلت عنوان الـURL بشكل صحيح. إذا كان الـURL صحيحاً فراسل إدارة السيرفر خاصتك لمزيد من المساعدة." + "الخادم غير متاح بسبب مشكلة في ملف .well-known: +%1$s" + "لا يدعم موفّر الحساب المحدد ميزة Sliding Sync. يلزم ترقية الخادم لاستخدام %1$s." + "غير مسموح لـ %1$s بالاتصال بـ %2$s." + "تم إعداد هذا التطبيق للسماح بـ: %1$s." + "موفّر الحساب %1$s غير مسموح به." + "عنوان الـ URL للسيرفر" + "أدخل عنوان نطاق." + "ما عنوان السيرفر خاصتك؟" + "اختر خادمك" + "إنشاء حساب" + "هذا الحساب محذوف." + "اسم المستخدم و/أو كلمة المرور غير صحيحة" + "معرّف المستخدم هذا غير صالح. التنسيق المتوقّع: \'@user:homeserver.org\'" + "تم إعداد هذا الخادم لاستخدام رموز التحديث. وهي غير مدعومة عند استخدام تسجيل الدخول بكلمة المرور." + "لا يتيح المخدِّم المختار تسجيلَ الدخول بكلمة سرّ أو OAuth. راجع المسؤول أو اختر مخدِّماً آخر." + "أدخِل تفاصيلك" + "Matrix عبارة عن شبكة مفتوحة للاتصالات الآمنة واللا مركزية." + "أهلاً بعودتك!" + "تسجيل الدخول إلى %1$s" + "فتح Element Classic" + "افتح Element Classic على جهازك" + "انتقل إلى الإعدادات > الأمان والخصوصية" + "في إدارة مفاتيح التشفير، اختر استرداد الرسائل المشفّرة" + "اتبع التعليمات لتفعيل تخزين مفاتيحك" + "عُد إلى %1$s" + "فعّل تخزين مفاتيحك قبل المتابعة إلى %1$s" + "الإصدار %1$s" + "جارٍ التحقق من الحساب" + "تسجيل الدخول يدوياً" + "تسجيل الدخول إلى %1$s" + "تسجيل الدخول باستخدام رمز QR" + "إنشاء حساب" + "مرحبًا بعودتك" + "مرحباً بك في أسرع %1$s على الإطلاق. مزوَّد من أجل سرعةٍ وبساطة." + "مرحباً بك في %1$s. المعزَّز، من أجل سرعةٍ وبساطة." + "Be in your element" + "جارٍ إنشاء اتصال آمن" + "تعذّر إنشاء اتصال آمن بالجهاز الجديد. لا تزال أجهزتك الحالية آمنة ولا داعي للقلق بشأنها." + "ماذا الآن؟" + "حاول تسجيل الدخول مجددًا باستخدام رمز QR إذا كانت هذه مشكلة في الشبكة." + "إذا واجهت المشكلة نفسها، جرّب شبكة Wi-Fi مختلفة أو استخدم بيانات الهاتف المحمول بدلًا من Wi-Fi." + "إذا لم ينجح ذلك، سجّل الدخول يدويًا" + "الاتصال غير آمن" + "سيُطلب منك إدخال الرقمين الظاهرين على هذا الجهاز." + "أدخل الرقم أدناه على جهازك الآخر" + "سجّل الدخول إلى جهازك الآخر ثم حاول مرة أخرى، أو استخدم جهازًا آخر تم تسجيل الدخول عليه بالفعل." + "لم يتم تسجيل الدخول على الجهاز الآخر" + "تم إلغاء تسجيل الدخول على الجهاز الآخر." + "تم إلغاء طلب تسجيل الدخول" + "تم رفض تسجيل الدخول على الجهاز الآخر." + "تم رفض تسجيل الدخول" + "لست بحاجة إلى فعل أي شيء آخر." + "جهازك الآخر مسجّل الدخول بالفعل" + "انتهت صلاحية تسجيل الدخول. يرجى المحاولة مرة أخرى." + "لم يكتمل تسجيل الدخول في الوقت المحدد" + "جهازك الآخر لا يدعم تسجيل الدخول إلى %s باستخدام رمز QR. + +جرّب تسجيل الدخول يدويًا، أو امسح رمز QR ضوئيًا باستخدام جهاز آخر." + "رمز QR غير مدعوم" + "لا يدعم موفّر حسابك %1$s." + "%1$s غير مدعوم" + "جاهز للمسح الضوئي" + "افتح %1$s على جهاز سطح مكتب" + "انقر على صورتك الرمزية" + "اختر %1$s" + "“ربط جهاز جديد”" + "امسح رمز QR ضوئيًا باستخدام هذا الجهاز" + "متاح فقط إذا كان موفّر حسابك يدعم ذلك." + "افتح %1$s على جهاز آخر للحصول على رمز QR" + "استخدم رمز QR المعروض على الجهاز الآخر." + "حاول مرة أخرى" + "رمز QR غير صحيح" + "الانتقال إلى إعدادات الكاميرا" + "تحتاج إلى منح %1$s إذنًا لاستخدام كاميرا جهازك للمتابعة." + "اسمح بالوصول إلى الكاميرا لمسح رمز QR ضوئيًا" + "امسح رمز QR ضوئيًا" + "البدء من جديد" + "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى." + "في انتظار جهازك الآخر" + "قد يطلب موفّر حسابك الرمز التالي للتحقق من تسجيل الدخول." + "رمز التحقق الخاص بك" + "تغيير مزوّد الحسابات" + "سيرفر خاص بموظّفي Element." + "Matrix عبارة عن شبكة مفتوحة للاتصالات الآمنة واللا مركزية." + "هذا المكان الذي تقطن فيه رسائلك — تماماً كاستخدامك مزوّد بريد إلكتروني للاحتفاظ برسائل البريد خاصّتك فيه." + "أنت على وشك تسجيل الدخول إلى %1$s" + "اختر موفّر الحساب" + "أنت على وشك إنشاء حساب في %1$s" + diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml index 3b9ffbe2dd..33b3741234 100644 --- a/features/login/impl/src/main/res/values-es/translations.xml +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -13,6 +13,9 @@ "Otro" "Usa un proveedor de cuentas diferente, como tu propio servidor privado o una cuenta de trabajo." "Cambiar proveedor de cuentas" + "Google Play" + "%1$s requiere la aplicación Element Pro. Descárgala de la tienda." + "Se requiere Element Pro" "No hemos podido acceder a este servidor base. Comprueba que has introducido correctamente la dirección. Si es correcta, ponte en contacto con el administrador de tu servidor base para obtener más ayuda." "El servidor no está disponible debido a un problema en el archivo .well-known: %1$s" @@ -25,7 +28,7 @@ "¿Cuál es la dirección de tu servidor?" "Selecciona tu servidor" "Crear cuenta" - "Esta cuenta ha sido desactivada." + "Esta cuenta ha sido eliminada." "Usuario y/o contraseña incorrectos" "Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'" "Este servidor está configurado para utilizar tokens de actualización. Estos no son compatibles cuando se utiliza el inicio de sesión basado en contraseña." @@ -34,11 +37,20 @@ "Matrix es una red abierta para una comunicación segura y descentralizada." "¡Hola de nuevo!" "Iniciar sesión en %1$s" + "Abrir Element Classic" + "Abre Element Classic en tu dispositivo" + "Ve a Ajustes > Seguridad y privacidad" + "En «Gestión de claves criptográficas», selecciona «Recuperación de mensajes cifrados»" + "Sigue las instrucciones para activar tu almacén de claves" + "Vuelve a %1$s" + "Activa tu almacén de claves antes de continuar con %1$s" "Versión %1$s" + "Comprobación de la cuenta" "Iniciar sesión manualmente" "Iniciar sesión en %1$s" "Iniciar sesión con un código QR" "Crear cuenta" + "¡Hola de nuevo!" "Bienvenido al %1$s más rápido de todos los tiempos. Diseñado para la velocidad y la simplicidad." "Bienvenido a %1$s. Vitaminado, para mayor rapidez y sencillez." "Siéntete en tu Elemento" @@ -57,6 +69,8 @@ "Solicitud de inicio de sesión cancelada" "El inicio de sesión se rechazó en el otro dispositivo." "Inicio de sesión rechazado" + "No tienes que hacer nada más." + "Tu otro dispositivo ya está conectado" "El inicio de sesión ha caducado. Inténtalo de nuevo." "El inicio de sesión no se completó a tiempo" "Tu otro dispositivo no admite el inicio de sesión en %s con un código QR. diff --git a/features/login/impl/src/main/res/values-fa/translations.xml b/features/login/impl/src/main/res/values-fa/translations.xml index 38c2c11eaa..ce0ab0da20 100644 --- a/features/login/impl/src/main/res/values-fa/translations.xml +++ b/features/login/impl/src/main/res/values-fa/translations.xml @@ -14,8 +14,14 @@ "استفاده از فراهم کنندهٔ حسابی دیگر چون کارساز خصوصی خوتان یا حسابی کاری." "تغییر فراهم کنندهٔ حساب" "پلی گپگل" + "برنامه Element Pro برای %1$s مورد نیاز است. لطفاً آن را از فروشگاه دانلود کنید." + "المنت پرو مورد نیاز است" "ما نتوانستیم به این کارساز خانگی برسیم. لطفاً بررسی کنید که URL کارساز اصلی را به درستی وارد کرده اید. اگر URL صحیح است، برای کمک بیشتر با مدیر کارساز خانگی خود تماس بگیرید." "سرور به دلیل مشکلی در فایل .well-known در دسترس نیست: %1$s" + "ارائه دهنده حساب انتخاب شده از همگام سازی کشویی پشتیبانی نمی کند. برای استفاده از %1$s، ارتقاء سرور لازم است." + "%1$s اجازه اتصال به %2$s را ندارد." + "این برنامه طوری پیکربندی شده است که اجازه موارد زیر را بدهد: %1$s." + "ارائه دهنده حساب %1$s مجاز نیست." "نشانی کارساز خانگی" "ورود نشانی دامنه." "نشانی کارسازتان چیست؟" @@ -30,11 +36,20 @@ "ماتریکس شبکه‌ای بار برای ارتباطات نامتمرکز و امن است." "خوش برگشتید!" "ورود به %1$s" + "باز کردن Element کلاسیک" + "Element Classic را روی دستگاه خود باز کنید" + "به تنظیمات > امنیت و حریم خصوصی بروید" + "در مدیریت کلیدهای رمزنگاری، بازیابی پیام‌های رمزگذاری‌شده را انتخاب کنید." + "برای فعال کردن فضای ذخیره‌سازی کلید، دستورالعمل‌ها را دنبال کنید" + "به %1$s برگردید" + "قبل از ادامه به %1$s، فضای ذخیره‌سازی کلید خود را فعال کنید" "نگارش %1$s" + "درحال بررسی حساب…" "ورود دستی" "ورود به %1$s" "ورود با کد QR" "ایجاد حساب" + "خوش برگشتید" "به سریع‌ترین %1$s خوش آمدید. بازطرّاحی شده برای سرعت و سادگی." "به %1$s خوش آمدید. بازطرّاحی شده برای سرعت و سادگی." "در المنتتان باشید" @@ -53,6 +68,8 @@ "درخواست ورد لغو شد" "ورود به دست افزارهٔ دیگر رد شد." "ورود رد شد" + "لازم نیست کار دیگری انجام دهید." + "دستگاه دیگر شما قبلاً وارد سیستم شده است" "ورود منقضی شد. لطفاً دوباره تلاش کنید." "ورود در زمان معیّن کامل نشد" "افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمی‌کند. @@ -67,6 +84,7 @@ "گزینش %1$s" "«پیوند افزارهٔ جدید»" "پویش کد پاس با این افزاره" + "فقط در صورتی موجود است که ارائه دهنده حساب شما از آن پشتیبانی کند." "گشودن %1$s روی افزاره‌ای دیگر برای گرفتن کد پاس" "استفاده از کد پاس نشان داده روی افزارهٔ دیگر." "تلاش دوباره" @@ -85,5 +103,6 @@ "ماتریکس شبکه‌ای بار برای ارتباطات نامتمرکز و امن است." "جایی که گفت‌وگوهایتان خواهند زیست — درست مثل استفاده‌تان از فراهم کنندهٔ رایانامه‌ای برای نگه داشتن رایانامه‌هایتان." "دارید به %1$s وارد می‌شوید" + "ارائه دهنده حساب را انتخاب کنید" "دارید روی %1$s حساب می‌سازید" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt index a05194d008..4e040a7556 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -12,9 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.api.LoginEntryPoint -import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.features.preferences.test.FakePreferencesEntryPoint import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow @@ -38,7 +37,7 @@ class DefaultLoginEntryPointTest { LoginFlowNode( buildContext = buildContext, plugins = plugins, - accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource = anAccountProviderDataSource(), oAuthActionFlow = FakeOAuthActionFlow(), appCoroutineScope = backgroundScope, elementClassicConnection = FakeElementClassicConnection(), diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt index ee4cea0532..fbfae984fc 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt @@ -11,15 +11,9 @@ package io.element.android.features.login.impl.accesscontrol import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.changeserver.AccountProviderAccessException -import io.element.android.features.wellknown.test.FakeWellknownRetriever -import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL -import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import kotlinx.coroutines.test.runTest import org.junit.Assert.assertThrows import org.junit.Test @@ -30,9 +24,7 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = true, - ), + enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), ) accessControl.expectNeedElementProException() } @@ -43,9 +35,7 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = false, // false here. isAllowedToConnectToHomeserver = false, - elementWellKnown = anElementWellKnown( - enforceElementPro = true, - ), + enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), ) accessControl.expectNeedElementProException() } @@ -55,8 +45,9 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false } ), ) accessControl.expectAllowed() @@ -67,19 +58,15 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = null, - ), ) accessControl.expectAllowed() } @Test - fun `foss build should allow using account provider twith missing wellknown`() = runTest { + fun `foss build should allow using account provider with missing wellknown`() = runTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = false, isAllowedToConnectToHomeserver = true, - elementWellKnown = null, ) accessControl.expectAllowed() } @@ -90,8 +77,10 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = false, isAllowedToConnectToHomeserver = false, allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { false }, + isElementProEnforcedResult = { false }, + defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -102,8 +91,9 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = true, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { true }, ), ) accessControl.expectAllowed() @@ -114,8 +104,9 @@ class DefaultAccountProviderAccessControlTest { val accessControl = createDefaultAccountProviderAccessControl( isEnterpriseBuild = true, isAllowedToConnectToHomeserver = true, - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), ) accessControl.expectAllowed() @@ -127,8 +118,10 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = true, isAllowedToConnectToHomeserver = false, allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), - elementWellKnown = anElementWellKnown( - enforceElementPro = true, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { false }, + isElementProEnforcedResult = { true }, + defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -140,8 +133,10 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild = true, isAllowedToConnectToHomeserver = false, allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), - elementWellKnown = anElementWellKnown( - enforceElementPro = false, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { false }, + isElementProEnforcedResult = { false }, + defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, ), ) accessControl.expectUnauthorizedAccountProviderException() @@ -151,25 +146,14 @@ class DefaultAccountProviderAccessControlTest { isEnterpriseBuild: Boolean = false, isAllowedToConnectToHomeserver: Boolean = false, allowedAccountProviders: List = emptyList(), - elementWellKnown: ElementWellKnown? = null, - ) = DefaultAccountProviderAccessControl( - enterpriseService = FakeEnterpriseService( - isEnterpriseBuild = isEnterpriseBuild, + enterpriseService: FakeEnterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, defaultHomeserverListResult = { allowedAccountProviders }, - ), - wellknownRetrieverFactory = FakeWellknownRetrieverFactory( - wellknownRetriever = FakeWellknownRetriever( - getElementWellKnownResult = { _, _ -> - if (elementWellKnown == null) { - WellknownRetrieverResult.NotFound - } else { - WellknownRetrieverResult.Success(elementWellKnown) - } - }, - ), - ), - temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), + isElementProEnforcedResult = { false }, + ) + ) = DefaultAccountProviderAccessControl( + isEnterpriseBuild = { isEnterpriseBuild }, + enterpriseService = enterpriseService, ) private fun DefaultAccountProviderAccessControl.expectNeedElementProException() { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt new file mode 100644 index 0000000000..25bc67144b --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceFixture.kt @@ -0,0 +1,25 @@ +/* + * 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.accountprovider + +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +fun anAccountProviderDataSource( + enterpriseService: EnterpriseService = FakeEnterpriseService(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined), +) = AccountProviderDataSource( + enterpriseService = enterpriseService, + appPreferencesStore = appPreferencesStore, + coroutineScope = coroutineScope, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt index f86df13335..30e25c8c3e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt @@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -24,7 +25,7 @@ class AccountProviderDataSourceTest { @Test fun `present - initial state`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState).isEqualTo( @@ -41,10 +42,10 @@ class AccountProviderDataSourceTest { @Test fun `present - initial state - matrix org`() = runTest { - val sut = AccountProviderDataSource( - FakeEnterpriseService( + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) } - ) + ), ) sut.flow.test { val initialState = awaitItem() @@ -62,10 +63,10 @@ class AccountProviderDataSourceTest { @Test fun `present - ensure that default homeserver is not star char`() = runTest { - val sut = AccountProviderDataSource( - FakeEnterpriseService( + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) } - ) + ), ) sut.flow.test { val initialState = awaitItem() @@ -83,7 +84,7 @@ class AccountProviderDataSourceTest { @Test fun `present - user change and reset`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) @@ -106,7 +107,7 @@ class AccountProviderDataSourceTest { @Test fun `present - set url and reset`() = runTest { - val sut = AccountProviderDataSource(FakeEnterpriseService()) + val sut = anAccountProviderDataSource() sut.flow.test { val initialState = awaitItem() assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) @@ -126,4 +127,47 @@ class AccountProviderDataSourceTest { assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) } } + + @Test + fun `present - defaults to the most recently used provider from history`() = runTest { + val sut = anAccountProviderDataSource( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com", "https://matrix.org"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://example.com") + } + } + + @Test + fun `present - history is ignored when the account provider is enforced`() = runTest { + val sut = anAccountProviderDataSource( + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf("https://enforced.org") } + ), + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://enforced.org") + } + } + + @Test + fun `present - reset returns to the most recently used provider from history`() = runTest { + val sut = anAccountProviderDataSource( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://example.com"), + ), + ) + sut.flow.test { + assertThat(awaitItem().url).isEqualTo("https://example.com") + sut.setUrl("https://other.com") + assertThat(awaitItem().url).isEqualTo("https://other.com") + sut.reset() + assertThat(awaitItem().url).isEqualTo("https://example.com") + } + } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt new file mode 100644 index 0000000000..0af8188f3f --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/SaveAccountProviderToHistoryTest.kt @@ -0,0 +1,50 @@ +/* + * 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.accountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SaveAccountProviderToHistoryTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `invoke saves the current account provider url to history`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val accountProviderDataSource = anAccountProviderDataSource() + accountProviderDataSource.setUrl("https://example.com") + val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore) + + sut() + + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com") + } + + @Test + fun `invoke saves the selected provider even after the exposed default has been reset`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val accountProviderDataSource = anAccountProviderDataSource(appPreferencesStore = appPreferencesStore) + accountProviderDataSource.setUrl("https://example.com") + // Simulate the login flow being torn down (e.g. across an OAuth round-trip), which reverts the + // exposed default; the user's selection must still be the one saved to history. + accountProviderDataSource.reset() + val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore) + + sut() + + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com") + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index c28fee77ad..ad997111e2 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -14,24 +14,18 @@ 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.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource 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.FakeWellknownRetrieverFactory -import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.architecture.AsyncData 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.FakeTemporaryMatrixClientFactory 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.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -64,6 +58,7 @@ class ChangeServerPresenterTest { authenticationService = authenticationService, enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), ).test { val initialState = awaitItem() @@ -72,7 +67,7 @@ class ChangeServerPresenterTest { val loadingState = awaitItem() assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) val successState = awaitItem() - assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(Unit)) + assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true))) } } @@ -86,6 +81,7 @@ class ChangeServerPresenterTest { createPresenter( enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), authenticationService = authenticationService, ).test { @@ -113,6 +109,7 @@ class ChangeServerPresenterTest { createPresenter( enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), authenticationService = authenticationService, ).test { @@ -136,6 +133,7 @@ class ChangeServerPresenterTest { enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = isAllowedToConnectToHomeserverResult, defaultHomeserverListResult = { listOf("element.io") }, + isElementProEnforcedResult = { false }, ), ).test { val initialState = awaitItem() @@ -159,23 +157,19 @@ class ChangeServerPresenterTest { @Test fun `present - change server element pro required error`() = runTest { - val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource -> - assertThat(homeserver).isEqualTo(A_HOMESERVER_URL) - WellknownRetrieverResult.Success( - anElementWellKnown( - enforceElementPro = true, - ) - ) - } + val isEnterpriseBuildLambda = lambdaRecorder { false } createPresenter( - wellknownRetriever = FakeWellknownRetriever( - getElementWellKnownResult = getElementWellKnownResult, - ), + isEnterpriseBuild = isEnterpriseBuildLambda, + enterpriseService = FakeEnterpriseService( + isElementProEnforcedResult = { true }, + ) ).test { val initialState = awaitItem() assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider)) + // Skip loading state + skipItems(1) val failureState = awaitItem() assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle @@ -183,7 +177,7 @@ class ChangeServerPresenterTest { assertThat( (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId ).isEqualTo("io.element.enterprise") - getElementWellKnownResult.assertions().isCalledOnce() + isEnterpriseBuildLambda.assertions().isCalledOnce() } } @@ -237,7 +231,7 @@ class ChangeServerPresenterTest { val permissionsPresenter = FakePermissionsPresenter() createPresenter( authenticationService = authenticationService, - enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }), + enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }, isElementProEnforcedResult = { false }), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), permissionsPresenter = permissionsPresenter, ).test { @@ -251,24 +245,23 @@ class ChangeServerPresenterTest { while (finalState.changeServerAction !is AsyncData.Success) { finalState = awaitItem() } - assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit)) + assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true))) } } private fun createPresenter( authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), enterpriseService: EnterpriseService = FakeEnterpriseService(), - wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(), + isEnterpriseBuild: () -> Boolean = { false }, ) = ChangeServerPresenter( authenticationService = authenticationService, accountProviderDataSource = accountProviderDataSource, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( enterpriseService = enterpriseService, - wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever), - temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), + isEnterpriseBuild = isEnterpriseBuild, ), localNetworkPermissionGate = LocalNetworkPermissionGate( advisor = localNetworkPermissionAdvisor, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt deleted file mode 100644 index 38e04b6e7f..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolverTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * 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.resolver - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker -import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import io.element.android.tests.testutils.testCoroutineDispatchers -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class HomeserverResolverTest { - @Test - fun `resolve - a server name is used as is`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("example.org").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org")), - ) - } - - @Test - fun `resolve - an url is used as is`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("https://example.org").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org")), - ) - } - - @Test - fun `resolve - a user id resolves the server name of the user id`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice:example.org").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org")), - ) - } - - @Test - fun `resolve - a user id with a port resolves the server name of the user id`() = runTest { - val checkResult = lambdaRecorder> { Result.success(it == "https://example.org:8448") } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice:example.org:8448").test { - assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448"))) - awaitComplete() - } - checkResult.assertions().isCalledExactly(1) - .withSequence( - listOf(value("https://example.org:8448")), - ) - } - - @Test - fun `resolve - an incomplete user id does not resolve anything`() = runTest { - val checkResult = lambdaRecorder> { Result.success(true) } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice").test { - awaitComplete() - } - sut.resolve("@alice:").test { - awaitComplete() - } - checkResult.assertions().isNeverCalled() - } - - @Test - fun `resolve - a user id with a too short server name does not resolve anything`() = runTest { - val checkResult = lambdaRecorder> { Result.success(true) } - val sut = createHomeserverResolver(checkResult) - sut.resolve("@alice:a.b").test { - awaitComplete() - } - checkResult.assertions().isNeverCalled() - } - - private fun TestScope.createHomeserverResolver( - checkResult: (String) -> Result, - ) = HomeserverResolver( - dispatchers = testCoroutineDispatchers(), - homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult), - localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), - ) -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt deleted file mode 100644 index 7d81f05c0c..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.changeaccountprovider - -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.changeserver.aChangeServerState -import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER -import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 -import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.test -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class ChangeAccountProviderPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { emptyList() } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isTrue() - } - } - - @Test - fun `present - fixed list of account providers`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { - listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2) - } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ), - AccountProvider( - url = "https://element.io", - title = "element.io", - subtitle = null, - isPublic = false, - isMatrixOrg = false, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isFalse() - } - } - - @Test - fun `present - opened list of account providers`() = runTest { - val presenter = ChangeAccountProviderPresenter( - changeServerPresenter = { aChangeServerState() }, - enterpriseService = FakeEnterpriseService( - defaultHomeserverListResult = { - listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER) - } - ), - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.accountProviders).isEqualTo( - listOf( - AccountProvider( - url = "https://matrix.org", - title = "matrix.org", - subtitle = null, - isPublic = true, - isMatrixOrg = true, - ) - ) - ) - assertThat(initialState.canSearchForAccountProviders).isTrue() - } - } -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt index 8d56386fa4..d4a1a74d99 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt @@ -8,8 +8,8 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource 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.classic.FakeElementClassicConnection @@ -249,7 +249,7 @@ private fun createPresenter( navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(), loginModePresenter: LoginModePresenter = createLoginModePresenter(), elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), isEnterpriseBuild: Boolean = false, ) = LoginWithClassicPresenter( userId = userId, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 52858b77af..229fc375a2 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -8,10 +8,16 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider +import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig +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.accountprovider.anAccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate 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.createLoginModePresenter @@ -23,6 +29,11 @@ import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +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.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest @@ -40,7 +51,7 @@ class ConfirmAccountProviderPresenterTest { val initialState = awaitItem() assertThat(initialState.isAccountCreation).isFalse() assertThat(initialState.submitEnabled).isTrue() - assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(initialState.accountProviderInput).isEqualTo("matrix.org") assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) } } @@ -57,14 +68,10 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) + cancelAndIgnoreRemainingEvents() } } @@ -80,14 +87,10 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -105,18 +108,14 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenOAuthCancelError(AN_EXCEPTION) defaultOAuthActionFlow.post(OAuthAction.GoBack()) - val cancelFailureState = awaitItem() + val cancelFailureState = awaitLoginMode { it is AsyncData.Failure } assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -134,17 +133,13 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOAuthActionFlow.post(OAuthAction.GoBack()) - val cancelFinalState = awaitItem() + val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -162,13 +157,12 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + awaitLoginMode { it is AsyncData.Loading } defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) - val cancelFinalState = awaitItem() + val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -186,20 +180,14 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) authenticationService.givenLoginError(AN_EXCEPTION) defaultOAuthActionFlow.post(OAuthAction.Success("aUrl")) - val cancelLoadingState = awaitItem() - assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val cancelFailureState = awaitItem() + val cancelFailureState = awaitLoginMode { it is AsyncData.Failure } assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -217,17 +205,13 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val successState = awaitLoginMode { it is AsyncData.Success } assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) - val successSuccessState = awaitItem() + val successSuccessState = awaitLoginMode { it is AsyncData.Loading } assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + cancelAndIgnoreRemainingEvents() } } @@ -243,11 +227,11 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val failureState = awaitItem() - assertThat(failureState.submitEnabled).isFalse() - assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + // The account provider validation fails, so the login is never attempted. + val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } + assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) + cancelAndIgnoreRemainingEvents() } } @@ -264,18 +248,15 @@ class ConfirmAccountProviderPresenterTest { presenter.test { val initialState = awaitItem() - // Submit will return an error - initialState.eventSink(ConfirmAccountProviderEvents.Continue) - - skipItems(1) // Loading + // Submit will return an error while validating the account provider + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) // Check an error was returned - val submittedState = awaitItem() - assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } // Assert the error is then cleared submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) - val clearedState = awaitItem() + val clearedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Uninitialized } assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) } } @@ -284,7 +265,7 @@ class ConfirmAccountProviderPresenterTest { fun `present - confirm account creation without oidc generates an error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( setHomeserverResult = { - Result.success(aMatrixHomeServerDetails()) + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) }, ) val presenter = createConfirmAccountProviderPresenter( @@ -293,15 +274,15 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) // Check an error was returned - val submittedState = awaitItem() + val submittedState = awaitLoginMode { it is AsyncData.Failure } assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) // Assert the error is then cleared submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) - val clearedState = awaitItem() + val clearedState = awaitLoginMode { it is AsyncData.Uninitialized } assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) + cancelAndIgnoreRemainingEvents() } } @@ -318,25 +299,214 @@ class ConfirmAccountProviderPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue) - skipItems(1) // Loading - val submittedState = awaitItem() - assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java) + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + val submittedState = awaitLoginMode { it is AsyncData.Success } assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - offers an autocomplete suggestion from the account provider history`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://randomcommunity.org"), + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accountProviderSuggestion).isNull() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderInput == "random" } + // Completion is offered without the https:// scheme, even though history stores the full URL. + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("randomcommunity.org") + } + } + + @Test + fun `present - continue accepts the autocomplete suggestion rather than the typed prefix`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + appPreferencesStore = InMemoryAppPreferencesStore( + homeserverHistory = listOf("https://randomcommunity.org"), + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderSuggestion != null } + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue applies the completed account provider back into the field`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://randomcommunity.org")), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random")) + val suggestionState = awaitState { it.accountProviderSuggestion != null } + suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org")) + // The field now renders the full accepted server rather than the typed prefix. + val appliedState = awaitState { it.accountProviderInput == "randomcommunity.org" } + assertThat(appliedState.accountProviderInput).isEqualTo("randomcommunity.org") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - offers the enterprise allow-list servers as autocomplete suggestions`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + enterpriseService = FakeEnterpriseService( + // Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete. + defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("ele")) + val suggestionState = awaitState { it.accountProviderInput == "ele" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("element.io") + } + } + + @Test + fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest { + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("matr")) + val suggestionState = awaitState { it.accountProviderInput == "matr" } + assertThat(suggestionState.accountProviderSuggestion).isEqualTo("matrix.org") + } + } + + @Test + fun `present - does not autocomplete a scheme-prefixed input`() = runTest { + // Candidates are bare hosts, so a scheme-prefixed input never completes them - the user completes by + // typing the bare host. This also avoids ever suggesting an insecure http scheme. + val presenter = createConfirmAccountProviderPresenter( + appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://mat")) + assertThat(awaitState { it.accountProviderInput == "https://mat" }.accountProviderSuggestion).isNull() + initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("http://mat")) + assertThat(awaitState { it.accountProviderInput == "http://mat" }.accountProviderSuggestion).isNull() + } + } + + @Test + fun `present - continue prepends https to a bare account provider`() = runTest { + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org")) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - continue configures the homeserver only once`() = runTest { + // The consolidated Continue flow validates the account provider and then signs in. Login reuses the + // details resolved during validation, so setHomeserver runs exactly once rather than once per phase. + val submittedUrls = mutableListOf() + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { url -> + submittedUrls.add(url) + Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) + awaitLoginMode { it is AsyncData.Success } + assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + cancelAndIgnoreRemainingEvents() + } + } + + /** + * Awaits until the emitted state's login mode matches [predicate], skipping the intermediate + * account-provider validation states, and returns that state. + */ + private suspend fun ReceiveTurbine.awaitLoginMode( + predicate: (AsyncData) -> Boolean, + ): ConfirmAccountProviderState = awaitState { predicate(it.loginModeState.loginMode) } + + private suspend fun ReceiveTurbine.awaitState( + predicate: (ConfirmAccountProviderState) -> Boolean, + ): ConfirmAccountProviderState { + while (true) { + val item = awaitItem() + if (predicate(item)) return item } } private fun createConfirmAccountProviderPresenter( params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), ) = ConfirmAccountProviderPresenter( params = params, accountProviderDataSource = accountProviderDataSource, + appPreferencesStore = appPreferencesStore, + enterpriseService = enterpriseService, loginModePresenter = createLoginModePresenter( authenticationService = matrixAuthenticationService, oAuthActionFlow = defaultOAuthActionFlow, ), + changeServerPresenter = ChangeServerPresenter( + authenticationService = matrixAuthenticationService, + accountProviderDataSource = accountProviderDataSource, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, + ), + isEnterpriseBuild = { false }, + ), + localNetworkPermissionGate = LocalNetworkPermissionGate( + advisor = FakeLocalNetworkPermissionAdvisor(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()), + ), + ), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 31a835cb8c..4dd2859dee 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -10,8 +10,9 @@ package io.element.android.features.login.impl.screens.loginpassword import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.AN_EXCEPTION @@ -21,8 +22,11 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME_2 import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -101,6 +105,32 @@ class LoginPasswordPresenterTest { } } + @Test + fun `present - successful login saves the account provider to history`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + val appPreferencesStore = InMemoryAppPreferencesStore() + createLoginPasswordPresenter( + authenticationService = authenticationService, + appPreferencesStore = appPreferencesStore, + ).test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + skipItems(1) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID)) + assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) + .containsExactly(AuthenticationConfig.MATRIX_ORG_URL) + } + } + @Test fun `present - submit with error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( @@ -157,10 +187,12 @@ class LoginPasswordPresenterTest { private fun createLoginPasswordPresenter( initialLogin: String = "", authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), ): LoginPasswordPresenter = LoginPasswordPresenter( initialLogin = initialLogin, authenticationService = authenticationService, accountProviderDataSource = accountProviderDataSource, + saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt index 3a128f2e4f..d76c94d5ea 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt @@ -81,6 +81,21 @@ class LoginPasswordViewTest : RobolectricTest() { ) } + @Test + fun `changing login removes carriage returns the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder, + ), + ) + val userNameHint = activity!!.getString(CommonStrings.common_username) + onNodeWithText(userNameHint).performTextInput("a\r\nb") + eventsRecorder.assertSingle( + LoginPasswordEvents.SetLogin("ab") + ) + } + @Test fun `clearing login invokes the expected event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -112,6 +127,21 @@ class LoginPasswordViewTest : RobolectricTest() { ) } + @Test + fun `changing password removes carriage returns the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder, + ), + ) + val passwordHint = activity!!.getString(CommonStrings.common_password) + onNodeWithText(passwordHint).performTextInput("a\r\nb") + eventsRecorder.assertSingle( + LoginPasswordEvents.SetPassword("ab") + ) + } + @Test fun `reveal password makes the password visible`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index fbee19f05e..2b64ae8b71 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -12,13 +12,14 @@ import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.OnBoardingConfig import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.IsEnterpriseBuild 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.accountprovider.SaveAccountProviderToHistory +import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.login.LoginModePresenter -import io.element.android.features.wellknown.test.FakeWellknownRetriever -import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -29,7 +30,6 @@ 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.A_HOMESERVER_URL_2 import io.element.android.libraries.matrix.test.A_LOGIN_HINT -import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.oauth.api.OAuthActionFlow @@ -38,10 +38,10 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData -import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.Flow @@ -186,6 +186,7 @@ class OnBoardingPresenterTest { enterpriseService = FakeEnterpriseService( defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), ) presenter.test { @@ -250,7 +251,7 @@ class OnBoardingPresenterTest { Result.failure(AN_EXCEPTION) }, ) - val accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()) + val accountProviderDataSource = anAccountProviderDataSource() val presenter = createPresenter( params = OnBoardingNode.Params( accountProvider = A_HOMESERVER_URL, @@ -259,6 +260,7 @@ class OnBoardingPresenterTest { ), enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, + isElementProEnforcedResult = { false }, ), loginModePresenter = createLoginModePresenter( authenticationService = authenticationService, @@ -295,20 +297,19 @@ private fun createPresenter( ), buildMeta: BuildMeta = aBuildMeta(), enterpriseService: EnterpriseService = FakeEnterpriseService(), - wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), + isEnterpriseBuild: IsEnterpriseBuild = { false }, rageshakeFeatureAvailability: () -> Flow = { flowOf(true) }, loginModePresenter: LoginModePresenter = createLoginModePresenter(), onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null }, sessionStore: SessionStore = InMemorySessionStore(), - accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), ) = OnBoardingPresenter( params = params, buildMeta = buildMeta, enterpriseService = enterpriseService, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( enterpriseService = enterpriseService, - wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever), - temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), + isEnterpriseBuild = isEnterpriseBuild, ), rageshakeFeatureAvailability = rageshakeFeatureAvailability, loginModePresenter = loginModePresenter, @@ -324,6 +325,8 @@ fun createLoginModePresenter( FakeLocalNetworkPermissionAdvisor(), permissionsPresenterFactory: PermissionsPresenter.Factory = FakePermissionsPresenterFactory(), + saveAccountProviderToHistory: SaveAccountProviderToHistory = + SaveAccountProviderToHistory(anAccountProviderDataSource(), InMemoryAppPreferencesStore()), ): LoginModePresenter = LoginModePresenter( oAuthActionFlow = oAuthActionFlow, authenticationService = authenticationService, @@ -331,4 +334,5 @@ fun createLoginModePresenter( advisor = localNetworkPermissionAdvisor, permissionsPresenterFactory = permissionsPresenterFactory, ), + saveAccountProviderToHistory = saveAccountProviderToHistory, ) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt deleted file mode 100644 index 29c5d4a86c..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.screens.searchaccountprovider - -import com.google.common.truth.Truth.assertThat -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 -import io.element.android.tests.testutils.test -import io.element.android.tests.testutils.testCoroutineDispatchers -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class SearchAccountProviderPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - assertThat(initialState.userInput).isEmpty() - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - } - } - - @Test - fun `present - error while checking login compatibility`() = runTest { - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("https://test.org") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org") - ) - ) - ) - } - } - - @Test - fun `present - enter text no result`() = runTest { - val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) }) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test"), - ) - ) - ) - } - } - - @Test - fun `present - enter text one result with wellknown`() = runTest { - val checkResult = lambdaRecorder> { - when (it) { - "https://test.org" -> Result.success(false) - "https://test.com" -> Result.success(false) - "https://test.io" -> Result.success(true) - "https://test" -> Result.success(false) - else -> error("should not happen") - } - } - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.io") - ) - ) - ) - checkResult.assertions().isCalledExactly(4) - .withSequence( - listOf(value("https://test.org")), - listOf(value("https://test.com")), - listOf(value("https://test.io")), - listOf(value("https://test")), - ) - } - } - - @Test - fun `present - enter text two results with wellknown`() = runTest { - val checkResult = lambdaRecorder> { - when (it) { - "https://test.org" -> Result.success(true) - "https://test.com" -> Result.success(false) - "https://test.io" -> Result.success(true) - "https://test" -> Result.success(false) - else -> error("should not happen") - } - } - val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) - val presenter = SearchAccountProviderPresenter( - homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()), - changeServerPresenter = { aChangeServerState() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) - val withInputState = awaitItem() - assertThat(withInputState.userInput).isEqualTo("test") - assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) - assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org"), - ) - ) - ) - assertThat(awaitItem().userInputResult).isEqualTo( - AsyncData.Success( - listOf( - aHomeserverData(homeserverUrl = "https://test.org"), - aHomeserverData(homeserverUrl = "https://test.io"), - ) - ) - ) - checkResult.assertions().isCalledExactly(4) - .withSequence( - listOf(value("https://test.org")), - listOf(value("https://test.com")), - listOf(value("https://test.io")), - listOf(value("https://test")), - ) - } - } -} diff --git a/features/logout/impl/src/main/res/values-ar/translations.xml b/features/logout/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..6825a5c54d --- /dev/null +++ b/features/logout/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,18 @@ + + + "أمتأكد من إزالة هذا الجهاز؟" + "إزالة هذا الجهاز" + "إزالة هذا الجهاز" + "تجري إزالة الجهاز…" + "هذا هو جهازك الوحيد. إذا أزلته، فستحتاج إلى مفتاح استرداد لتأكيد هويتك الرقمية واستعادة محادثاتك المشفّرة في المرة التالية التي تسجّل فيها الدخول." + "أنت على وشك فقدان الوصول إلى محادثاتك المشفّرة" + "كان النسخ الاحتياطي لمفاتيحك لا يزال جاريًا عندما أصبحت غير متصل. أعد الاتصال حتى يمكن نسخ مفاتيحك احتياطيًا قبل إزالة هذا الجهاز." + "لا يزال النسخ الاحتياطي لمفاتيحك جاريًا" + "يُرجى الانتظار حتى تكتمل هذه العملية قبل إزالة هذا الجهاز." + "لا يزال النسخ الاحتياطي لمفاتيحك جاريًا" + "إزالة هذا الجهاز" + "هذا هو جهازك الوحيد. إذا أزلته، فستحتاج إلى مفتاح استرداد لتأكيد هويتك الرقمية واستعادة محادثاتك المشفّرة في المرة التالية التي تسجّل فيها الدخول." + "أنت على وشك فقدان الوصول إلى محادثاتك المشفّرة" + "هذا هو جهازك الوحيد. إذا أزلته، فستحتاج إلى مفتاح استرداد لتأكيد هويتك الرقمية واستعادة محادثاتك المشفّرة في المرة التالية التي تسجّل فيها الدخول." + "تأكد من أن لديك إمكانية الوصول إلى مفتاح الاسترداد الخاص بك قبل إزالة هذا الجهاز" + diff --git a/features/logout/impl/src/main/res/values-es/translations.xml b/features/logout/impl/src/main/res/values-es/translations.xml index 94d0ec56a8..6c451c0834 100644 --- a/features/logout/impl/src/main/res/values-es/translations.xml +++ b/features/logout/impl/src/main/res/values-es/translations.xml @@ -1,18 +1,18 @@ - "¿Estás seguro de que quieres cerrar sesión?" - "Cerrar sesión" - "Cerrar sesión" - "Cerrando sesión…" - "Estás a punto de cerrar tu última sesión. Si cierras sesión ahora, perderás el acceso a tus mensajes cifrados." - "Has desactivado la copia de seguridad" - "Se estaba haciendo una copia de seguridad de tus claves cuando te desconectaste. Vuelve a conectarte para que se haga una copia de seguridad de tus claves antes de desconectarte." + "¿Estás seguro de que quieres eliminar este dispositivo?" + "Eliminar este dispositivo" + "Eliminar este dispositivo" + "Eliminando dispositivo…" + "Este es tu único dispositivo. Si lo eliminas, necesitarás una clave de recuperación para poder confirmar tu identidad digital y restaurar tus chats cifrados la próxima vez que inicies sesión." + "Estás a punto de perder acceso a tus chats cifrados" + "Se estaba haciendo una copia de seguridad de tus claves cuando te desconectaste. Vuelve a conectarte para que se haga una copia de seguridad de tus claves antes de eliminar este dispositivo." "Se sigue guardando una copia de seguridad de tus claves" - "Espera a que se complete antes de cerrar sesión." + "Espera a que se complete antes de eliminar este dispositivo." "Se sigue guardando una copia de seguridad de tus claves" - "Cerrar sesión" - "Estás a punto de cerrar tu última sesión. Si cierras sesión ahora, perderás el acceso a tus mensajes cifrados." - "La recuperación no está configurada" - "Estás a punto de cerrar tu última sesión. Si cierras la sesión ahora, podrías perder el acceso a tus mensajes cifrados." - "¿Has guardado tu clave de recuperación?" + "Eliminar este dispositivo" + "Este es tu único dispositivo. Si lo eliminas, necesitarás una clave de recuperación para poder confirmar tu identidad digital y restaurar tus chats cifrados la próxima vez que inicies sesión." + "Estás a punto de perder el acceso a tus chats cifrados." + "Este es tu único dispositivo. Si lo eliminas, necesitarás una clave de recuperación para poder confirmar tu identidad digital y restaurar tus chats cifrados la próxima vez que inicies sesión." + "Asegúrate de tener acceso a tu clave de recuperación antes de eliminar este dispositivo" diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5911de7372..ad71ada70d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -178,8 +178,8 @@ fun MessagesView( fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") - val eventId = event.eventId ?: return - if (eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return + val eventId = event.eventId + if (eventId != null && eventContentValidationState[eventId].getCurrentOverallState() != ContentValidationValue.Valid) return val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) if (hideKeyboard) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 5f12bf4be1..2974b99696 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -86,6 +86,7 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.services.analytics.api.AnalyticsService @@ -103,7 +104,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -459,6 +462,14 @@ class MessageComposerPresenter( val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger) + // Refresh the room members, which are only loaded on demand, when a mention starts + launch { + suggestionSearchTrigger + .distinctUntilChangedBy { it?.type } + .filter { it?.type == SuggestionType.Mention } + .collect { room.updateMembers() } + } + val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource .getAllRoomAliasSuggestions() .stateIn(this, SharingStarted.Lazily, emptyList()) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt index 6aabf97f2c..d6a55c0563 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -84,6 +85,7 @@ internal fun MessagesViewTopBar( val roundedCornerShape = RoundedCornerShape(8.dp) Row( modifier = Modifier + .heightIn(min = 48.dp) .clip(roundedCornerShape) .clickable { onRoomDetailsClick() } .semantics { heading() }, diff --git a/features/messages/impl/src/main/res/values-ar/translations.xml b/features/messages/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..80c296fa94 --- /dev/null +++ b/features/messages/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,114 @@ + + + "مرسل الحدث لا يطابق مالك الجهاز الذي أرسله." + "لا يمكن ضمان أصالة هذه الرسالة المشفّرة على هذا الجهاز." + "تم التشفير بواسطة مستخدم تم التحقق منه سابقًا." + "غير مشفّرة." + "تم التشفير بواسطة جهاز غير معروف أو محذوف." + "تم التشفير بواسطة جهاز لم يتحقق منه مالكه." + "تم التشفير بواسطة مستخدم لم يتم التحقق منه." + "قلب الصورة أفقيًا" + "مقلوبة أفقيًا" + "الأصلية" + "قلب الصورة رأسيًا" + "مقلوبة رأسيًا" + "الأصلية" + "تدوير الصورة إلى اليسار" + + "%1$d درجات" + "%1$d درجة" + "%1$d درجتان" + "%1$d درجات" + "%1$d درجة" + "%1$d درجة" + + "تعديل الصورة" + "قد لا تكون التعليقات التوضيحية مرئية للأشخاص الذين يستخدمون تطبيقات أقدم." + "اضغط لتغيير جودة رفع الفيديو" + "تعذّر رفع الملف." + "تعذّرت معالجة الوسائط من أجل رفعها، يرجى المحاولة مجدداً." + "تعذّر رفع الوسائط، يرجى المحاولة مجدداً." + "الحد الأقصى المسموح به لحجم الملف هو %1$s." + "الملف كبير جدًا ولا يمكن رفعه" + "العنصر %1$d من %2$d" + "تحسين جودة الصورة" + "جارٍ المعالجة…" + "إضافة وسائط" + "حظر المستخدم" + "حدِّد على هذا الخيار إذا كنت تريد إخفاء كل الرسائل الحالية والمستقبلية من هذا المستخدم" + "ستُبلَّغ هذه الرسالة إلى إدارة السيرفر خاصتك. لن يكونوا قادرين على قراءة أي رسالة مشفرة." + "سبب إبلاغك عن هذا المحتوى" + "الكاميرا" + "التقاط صورة" + "تسجيل مقطع فيديو" + "مرفق" + "معرض الصور والفيديوهات" + "مشاركة الموقع الجغرافي" + "استفتاء" + "تنسيق النص" + "سجل الرسائل غير متوفر حالياً." + "سجل الرسائل غير متاح في هذه الغرفة. تحقّق من هذا الجهاز لرؤية سجل رسائلك." + "هل تريد دعوته مجدداً؟" + "أنت لوحدك في هذه المحادثة" + "إشعار الغرفة بأكملها" + "الجميع" + "أرسل مجدداً" + "فشل إرسال رسالتك" + "أضف تفاعل" + "هنا بداية %1$s" + "هنا بداية المحادثة" + "مكالمة غير مدعومة. اسأل المتصل عما إذا كان بإمكانه استخدام تطبيق Element X الجديد." + "أظهر أقل" + "نُسِخت الرسالة" + "لا تملك الإذن بالنشر في هذه الغرفة" + + "تفاعل %1$d أعضاء بـ %2$s" + "تفاعل %1$d عضو بـ %2$s" + "تفاعل %1$d عضوان بـ %2$s" + "تفاعل %1$d أعضاء بـ %2$s" + "تفاعل %1$d عضوًا بـ %2$s" + "تفاعل %1$d عضو بـ %2$s" + + + "تفاعلت أنت و%1$d أعضاء بـ %2$s" + "تفاعلت أنت و%1$d عضو بـ %2$s" + "تفاعلت أنت و%1$d عضوان بـ %2$s" + "تفاعلت أنت و%1$d أعضاء بـ %2$s" + "تفاعلت أنت و%1$d عضوًا بـ %2$s" + "تفاعلت أنت و%1$d عضو بـ %2$s" + + "تفاعلت بـ %1$s" + "أظهر أقل" + "أظهر المزيد" + "اعرض ملخص التفاعلات" + "جديدة" + + "%1$d تغيير في الغرفة" + "%1$d تغيير في الغرفة" + "%1$d تغيير في الغرفة" + "%1$d تغييرات في الغرفة" + "%1$d تغيير في الغرفة" + "%1$d تغيير في الغرفة" + + "الانتقال إلى الغرفة الجديدة" + "استُبدلت هذه الغرفة ولم تعد نشطة" + "عرض الرسائل القديمة" + "هذه الغرفة هي استمرار لغرفة أخرى" + + "%1$s و%2$s و%3$d آخرون" + "%1$s و%2$s و%3$d آخر" + "%1$s و%2$s و%3$d آخران" + "%1$s و%2$s و%3$d آخرون" + "%1$s و%2$s و%3$d آخر" + "%1$s و%2$s و%3$d آخر" + + + "%1$s يكتبون الآن" + "%1$s يكتب الآن" + "%1$s يكتبان الآن" + "%1$s يكتبون الآن" + "%1$s يكتبون الآن" + "%1$s يكتبون الآن" + + "%1$s و%2$s" + diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml index 0a0b57cc5e..ed725bc9d2 100644 --- a/features/messages/impl/src/main/res/values-es/translations.xml +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -7,9 +7,29 @@ "Cifrado por un dispositivo desconocido o eliminado." "Cifrado por un dispositivo no verificado por su propietario." "Cifrado por un usuario no verificado." + "Voltear la imagen horizontalmente" + "Volteada horizontalmente" + "Original" + "Voltear la imagen verticalmente" + "Volteada verticalmente" + "Original" + "Rotar la imagen hacia la izquierda" + + "%1$d grado" + "%1$d grados" + + "Editar foto" "Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas." + "Toca para cambiar la calidad de subida del vídeo" + "No se pudo subir el archivo." "Error al procesar el contenido multimedia, por favor inténtalo de nuevo." "Error al subir el contenido multimedia, por favor inténtalo de nuevo." + "El tamaño máximo permitido de archivo es de %1$s." + "El archivo es demasiado grande para subirlo" + "Elemento %1$d de %2$d" + "Optimizar la calidad de la imagen" + "Procesando…" + "Añadir contenido multimedia" "Bloquear usuario" "Marca esta casilla si quieres ocultar todos los mensajes actuales y futuros de este usuario" "Se denunciará este mensaje al administrador de tu servidor base. No será capaz de leer ningún mensaje cifrado." @@ -19,7 +39,7 @@ "Grabar video" "Archivo adjunto" "Biblioteca de fotos y vídeos" - "Ubicación" + "Compartir ubicación" "Encuesta" "Formato de texto" "El historial de mensajes no está disponible en este momento." @@ -37,10 +57,23 @@ "Mostrar menos" "Mensaje copiado" "No tienes permiso para publicar en esta sala" + + "%1$d miembro reaccionó con %2$s" + "%1$d miembros reaccionaron con %2$s" + + + "Tú y %1$d miembro reaccionasteis con %2$s" + "Tú y %1$d miembros reaccionasteis con %2$s" + + "Reaccionaste con %1$s" "Mostrar menos" "Mostrar más" "Mostrar todas las reacciones" "Nuevos" + + "%1$d mensaje eliminado" + "%1$d mensajes eliminados" + "%1$d cambio en la sala" "%1$d cambios en la sala" diff --git a/features/messages/impl/src/main/res/values-fa/translations.xml b/features/messages/impl/src/main/res/values-fa/translations.xml index 4f8f737f9c..f10d9ead33 100644 --- a/features/messages/impl/src/main/res/values-fa/translations.xml +++ b/features/messages/impl/src/main/res/values-fa/translations.xml @@ -1,14 +1,35 @@ + "فرستنده رویداد با صاحب دستگاهی که آن را ارسال کرده است، مطابقت ندارد." "اعتبار این پیام رمز شده نمی‌تواند روی این افزاره تأیید شود." "رمز شده به دست کاربری از پیش تأیید شده." "رمز نشده." "رمز شده به دست افزاره‌ای ناشناخته یا حذف شده." "رمز شده به دست افزاره‌ای که از سوی مالکش تأیید نشده." "رمز شده به دست کاربری تأیید نشده." + "تصویر را به صورت افقی بچرخانید" + "به صورت افقی برگردانده شده" + "اصلی" + "تصویر را به صورت عمودی بچرخانید" + "به صورت عمودی برگردانده شده" + "اصلی" + "تصویر را به سمت چپ بچرخانید" + + "درجه %1$d" + "درجه‌های %1$d" + + "ویرایش عکس" + "ممکن است زیرنویس‌ها برای افرادی که از برنامه‌های قدیمی‌تر استفاده می‌کنند قابل مشاهده نباشند." + "برای تغییر کیفیت آپلود ویدیو، ضربه بزنید" + "فایل قابل آپلود نبود." "پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید." "بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید." + "حداکثر اندازه مجاز فایل %1$s است." + "فایل برای آپلود خیلی بزرگ است" + "مورد %1$d از %2$d" + "بهینه سازی کیفیت تصویر" "پردازش کردن…" + "افزودن رسانه" "انسداد کاربر" "اگر می‌خواهید همه پیام‌های فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید" "این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود." @@ -32,13 +53,27 @@ "افزودن شکلک" "آغاز %1$s است." "این، آغاز گفت‌وگوست." + "تماس پشتیبانی نشده. بپرسید که آیا تماس گیرنده می‌تواند از برنامه جدید Element X استفاده کند یا خیر." "نمایش کم‌تر" "پیام رونوشت شد" "اجازهٔ فرستادن به این اتاق را ندارید" + + "عضو %1$d با %2$s واکنش نشان داد" + "اعضای %1$d با %2$s واکنش نشان دادند" + + + "شما و عضو %1$d با %2$s واکنش نشان دادید" + "شما و اعضای %1$d با %2$s واکنش نشان دادید" + + "شما با %1$s واکنش نشان دادید" "نمایش کم‌تر" "نمایش بیش‌تر" "نمایش خلاصهٔ واکنش‌ها" "جدید" + + "پیام حذف شده %1$d" + "پیام‌های حذف شده %1$d" + "%1$dتغییر اتاق" "%1$dتغییر اتاق" diff --git a/features/messages/impl/src/main/res/values-fi/translations.xml b/features/messages/impl/src/main/res/values-fi/translations.xml index 3705047087..308ae57f2b 100644 --- a/features/messages/impl/src/main/res/values-fi/translations.xml +++ b/features/messages/impl/src/main/res/values-fi/translations.xml @@ -70,6 +70,10 @@ "Näytä lisää" "Näytä reaktioiden yhteenveto" "Uusi" + + "%1$d poistettu viesti" + "%1$d poistettua viestiä" + "%1$d muutos huoneeseen" "%1$d muutosta huoneeseen" diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml index 0062421326..6d10d9a493 100644 --- a/features/messages/impl/src/main/res/values-fr/translations.xml +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -70,6 +70,10 @@ "Afficher plus" "Afficher le résumé des réactions" "Nouveau" + + "%1$d message supprimé" + "%1$d messages supprimés" + "%1$d changement dans le salon" "%1$d changements dans le salon" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 2800c737ad..1350f1dfa0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -176,6 +176,33 @@ class MessagesViewTest : RobolectricTest() { callback.assertSuccess() } + @Test + fun `clicking on an Event which has not been sent yet invoke expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + val localEcho = aTimelineItemEvent( + isMine = true, + content = aTimelineItemTextContent(), + ).copy(eventId = null) + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = persistentListOf(localEcho), + ), + eventSink = eventsRecorder + ) + val callback = EnsureCalledOnceWithTwoParamsAndResult( + expectedParam1 = true, + expectedParam2 = localEcho, + result = true, + ) + setMessagesView( + state = state, + onEventClick = callback, + ) + // Cannot perform click on "Text", it's not detected. Use tag instead + onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() + callback.assertSuccess() + } + @Test fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() { `long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 07b9a4e330..4170ae41ab 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -1056,7 +1056,8 @@ class MessageComposerPresenterTest : RobolectricTest() { baseRoom = FakeBaseRoom( roomPermissions = FakeRoomPermissions( canTriggerRoomNotification = true, - ) + ), + updateMembersResult = {}, ), typingNoticeResult = { Result.success(Unit) } ).apply { @@ -1103,6 +1104,55 @@ class MessageComposerPresenterTest : RobolectricTest() { } } + @Test + fun `present - mention suggestions refresh the room members`() = runTest { + val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) + val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) + val updateMembersResult = lambdaRecorder { } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canTriggerRoomNotification = true, + ), + updateMembersResult = updateMembersResult, + ), + typingNoticeResult = { Result.success(Unit) } + ).apply { + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(currentUser, bob), + ) + ) + givenRoomInfo(aRoomInfo(isDirect = false)) + } + val presenter = createPresenter( + room = room, + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> emptyList() }, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + skipItems(1) + assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob)) + updateMembersResult.assertions().isCalledOnce() + + // Typing after the `@` must not refresh the members again + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 1, SuggestionType.Mention, "b"))) + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 2, SuggestionType.Mention, "bo"))) + advanceUntilIdle() + updateMembersResult.assertions().isCalledOnce() + + // Starting a new mention refreshes them again + initialState.eventSink(MessageComposerEvent.SuggestionReceived(null)) + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + advanceUntilIdle() + updateMembersResult.assertions().isCalledExactly(2) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - room mention suggestions no permission`() = runTest { val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) @@ -1113,7 +1163,8 @@ class MessageComposerPresenterTest : RobolectricTest() { baseRoom = FakeBaseRoom( roomPermissions = FakeRoomPermissions( canTriggerRoomNotification = false, - ) + ), + updateMembersResult = {}, ), typingNoticeResult = { Result.success(Unit) } ).apply { @@ -1144,6 +1195,7 @@ class MessageComposerPresenterTest : RobolectricTest() { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true), + updateMembersResult = {}, ), typingNoticeResult = { Result.success(Unit) } ).apply { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 7d3b6cf647..fb44bb4e80 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -60,6 +60,7 @@ import org.junit.Rule import org.junit.Test import java.io.File import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") @@ -67,8 +68,14 @@ class DefaultVoiceMessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + private val startRecordResult = lambdaRecorder { } + private val stopRecordResult = lambdaRecorder { } + private val deleteRecordingResult = lambdaRecorder { } private val voiceRecorder = FakeVoiceRecorder( - recordingDuration = RECORDING_DURATION + recordingDuration = RECORDING_DURATION, + startRecordResult = startRecordResult, + stopRecordResult = stopRecordResult, + deleteRecordingResult = deleteRecordingResult, ) private val analyticsService = FakeAnalyticsService() private val sendVoiceMessageResult = @@ -97,7 +104,9 @@ class DefaultVoiceMessageComposerPresenterTest { companion object { private val RECORDING_DURATION = 1.seconds + private val FIRST_LEVEL_DURATION = 500.milliseconds private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList()) + private val FIRST_RECORDING_STATE = VoiceMessageState.Recording(FIRST_LEVEL_DURATION, listOf(0.1f).toImmutableList()) } @Test @@ -106,7 +115,7 @@ class DefaultVoiceMessageComposerPresenterTest { presenter.test { val initialState = awaitItem() assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() testPauseAndDestroy(initialState) } @@ -118,9 +127,10 @@ class DefaultVoiceMessageComposerPresenterTest { presenter.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + assertThat(awaitItem().voiceMessageState).isEqualTo(FIRST_RECORDING_STATE) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -133,13 +143,18 @@ class DefaultVoiceMessageComposerPresenterTest { val voiceRecorder = FakeVoiceRecorder( levels = levels, recordingDuration = RECORDING_DURATION, + startRecordResult = { }, + stopRecordResult = { }, + deleteRecordingResult = { }, ) val presenter = createDefaultVoiceMessageComposerPresenter( voiceRecorder = voiceRecorder, ) presenter.test { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) - skipItems(numberOfLevels / 2 - 1) + + // Skip until we reach the final state, which should have the last 128 levels + skipItems(numberOfLevels - 1) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java) val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording @@ -221,7 +236,8 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) - voiceRecorder.assertCalls(started = 1, stopped = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) cancelAndIgnoreRemainingEvents() } @@ -235,7 +251,9 @@ class DefaultVoiceMessageComposerPresenterTest { awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel)) val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(true)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } } @@ -249,7 +267,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -265,8 +285,10 @@ class DefaultVoiceMessageComposerPresenterTest { } // Nothing should happen - assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0) + assertThat(finalState.voiceMessageState).isEqualTo(FIRST_RECORDING_STATE) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isNeverCalled() + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -282,7 +304,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) } - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -299,7 +323,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem().also { assertThat(it.voiceMessageState).isEqualTo(aPausedState()) } - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -340,7 +366,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -360,7 +388,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -377,7 +407,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -455,7 +487,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -476,7 +510,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -499,7 +535,9 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true)) sendVoiceMessageResult.assertions().isNeverCalled() assertThat(analyticsService.trackedErrors).isEmpty() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isNeverCalled() testPauseAndDestroy(finalState) } @@ -527,7 +565,9 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = awaitItem() assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isCalledOnce() - voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) + deleteRecordingResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -577,7 +617,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) sendVoiceMessageResult.assertions().isNeverCalled() assertThat(analyticsService.trackedErrors).hasSize(1) - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() testPauseAndDestroy(initialState) } @@ -586,8 +626,14 @@ class DefaultVoiceMessageComposerPresenterTest { @Test fun `present - record error - security exceptions are tracked`() = runTest { val exception = SecurityException("") - voiceRecorder.givenThrowsSecurityException(exception) - val presenter = createDefaultVoiceMessageComposerPresenter() + val startRecordResult = lambdaRecorder { throw exception } + val voiceRecorder = FakeVoiceRecorder( + recordingDuration = RECORDING_DURATION, + startRecordResult = startRecordResult, + ) + val presenter = createDefaultVoiceMessageComposerPresenter( + voiceRecorder = voiceRecorder, + ) presenter.test { val initialState = awaitItem() initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) @@ -596,7 +642,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(analyticsService.trackedErrors).containsExactly( VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception) ) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(initialState) } @@ -616,7 +662,8 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) - voiceRecorder.assertCalls(stopped = 1) + startRecordResult.assertions().isNeverCalled() + stopRecordResult.assertions().isCalledOnce().with(value(false)) permissionsPresenter.setPermissionGranted() @@ -625,7 +672,8 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(stopped = 1, started = 1) + startRecordResult.assertions().isCalledOnce() + stopRecordResult.assertions().isCalledOnce().with(value(false)) testPauseAndDestroy(finalState) } @@ -659,7 +707,7 @@ class DefaultVoiceMessageComposerPresenterTest { val finalState = expectMostRecentItem() assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) - voiceRecorder.assertCalls(started = 1) + startRecordResult.assertions().isCalledOnce() testPauseAndDestroy(finalState) } @@ -696,7 +744,7 @@ class DefaultVoiceMessageComposerPresenterTest { assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(it.showPermissionRationaleDialog).isTrue() } - voiceRecorder.assertCalls(started = 0) + startRecordResult.assertions().isNeverCalled() cancelAndIgnoreRemainingEvents() testPauseAndDestroy(finalState) diff --git a/features/poll/api/src/main/res/values-ar/translations.xml b/features/poll/api/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..e55d1c8710 --- /dev/null +++ b/features/poll/api/src/main/res/values-ar/translations.xml @@ -0,0 +1,13 @@ + + + + "%1$d بالمئة من إجمالي الأصوات" + "%1$d بالمئة من إجمالي الأصوات" + "%1$d بالمئة من إجمالي الأصوات" + "%1$d بالمئة من إجمالي الأصوات" + "%1$d بالمئة من إجمالي الأصوات" + "%1$d بالمئة من إجمالي الأصوات" + + "ٍسيتم حذف الإختيار السابق" + "هذه هي الإجابة الفائزة" + diff --git a/features/poll/api/src/main/res/values-es/translations.xml b/features/poll/api/src/main/res/values-es/translations.xml index 10e1193163..b69a550320 100644 --- a/features/poll/api/src/main/res/values-es/translations.xml +++ b/features/poll/api/src/main/res/values-es/translations.xml @@ -1,4 +1,9 @@ + + "%1$d por ciento de todos los votos" + "%1$d por ciento de todos los votos" + + "Eliminará la selección anterior" "Esta es la respuesta ganadora" diff --git a/features/poll/api/src/main/res/values-fa/translations.xml b/features/poll/api/src/main/res/values-fa/translations.xml index af9620c7e8..1885c72e1b 100644 --- a/features/poll/api/src/main/res/values-fa/translations.xml +++ b/features/poll/api/src/main/res/values-fa/translations.xml @@ -1,5 +1,9 @@ + + "درصد %1$d از کل آرا" + "درصد %1$d از کل آرا" + "گزینش پیشین را برخواهد داشت" "این پاسخ برنده است" diff --git a/features/poll/impl/src/main/res/values-ar/translations.xml b/features/poll/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..02c12f72d8 --- /dev/null +++ b/features/poll/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,20 @@ + + + "إضافة خيار" + "أظهر النتائج فقط حين انتهاء الاستفتاء" + "إخفاء الأصوات" + "الخيار %1$d" + "لم تُحفظ تغييراتك. هل أنت متأكد من أنك تريد الرجوع؟" + "حذف الخيار %1$s" + "السؤال أو الموضوع" + "عن ماذا يدور هذا الاستفتاء؟" + "إنشاء استفتاء" + "هل أنت متأكد من رغبتك في حذف هذا الاستطلاع؟" + "حذف الاستطلاع" + "تعديل الاستفتاء" + "تعذّر العثور على أي استطلاعات جارية." + "تعذّر العثور على أي استطلاعات مغلقة." + "جارية" + "مغلقة" + "الاستطلاعات" + diff --git a/features/poll/impl/src/main/res/values-fa/translations.xml b/features/poll/impl/src/main/res/values-fa/translations.xml index eeedc6c7a0..fd6469a389 100644 --- a/features/poll/impl/src/main/res/values-fa/translations.xml +++ b/features/poll/impl/src/main/res/values-fa/translations.xml @@ -5,6 +5,7 @@ "نهفتن رأی‌ها" "گزینهٔ %1$d" "تغییراتتان ذخیره نشده‌اند. مطمئنید که می‌خواهید برگردید؟" + "حذف گزینه %1$s" "پرسش یا موضوع" "این نظرسنجی دربارهٔ چیست؟" "ایجاد نظرسنجی" diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index ac1ba06cf6..c5a9b430ee 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -30,6 +30,7 @@ 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.core.data.ByteUnit +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId @@ -50,6 +51,7 @@ class DeveloperSettingsPresenter( private val databaseSizesUseCase: GetDatabaseSizesUseCase, private val fileSizeFormatter: FileSizeFormatter, private val markAllRoomsAsRead: MarkAllRoomsAsRead, + private val buildMeta: BuildMeta, ) : Presenter { @Composable override fun present(): DeveloperSettingsState { @@ -117,7 +119,7 @@ class DeveloperSettingsPresenter( databaseSizes = databaseSizes.value, clearCacheAction = clearCacheAction.value, markAllRoomsAsReadAction = markAllRoomsAsReadAction.value, - isEnterpriseBuild = enterpriseService.isEnterpriseBuild, + isEnterpriseBuild = buildMeta.isEnterpriseBuild, showColorPicker = showColorPicker, deviceId = deviceId, eventSink = ::handleEvent, diff --git a/features/preferences/impl/src/main/res/values-ar/translations.xml b/features/preferences/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..92f4539cd3 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,115 @@ + + + "للتأكد من عدم تفويت أي مكالمة مهمة، يُرجى تغيير إعداداتك للسماح بإشعارات ملء الشاشة عندما يكون هاتفك مقفلاً." + "حسّن تجربة مكالماتك" + "اختر طريقة استقبال الإشعارات" + "وضع المطوّر" + "فعّله للوصول إلى الميزات والوظائف الخاصة بالمطورين." + "عنوان URL الأساسي المخصص لـ Element Call" + "عيّن عنوان URL أساسيًا مخصصًا لـ Element Call." + "عنوان URL غير صالح، يُرجى التأكد من تضمين البروتوكول (http/https) والعنوان الصحيح." + "إخفاء الصور الرمزية في طلبات الدعوة إلى الغرف" + "إخفاء معاينات الوسائط في المخطط الزمني" + "المختبرات" + "المسافة التي يجب أن تتحركها ليتم إرسال تحديث." + "تأكد من تفعيل \"الموقع الدقيق\" لهذا التطبيق. لتغيير الإذن، انتقل إلى %1$s." + "إعدادات التطبيق" + "تحديثات الموقع المباشر" + + "كل %1$d أمتار" + "كل %1$d متر" + "كل %1$d مترين" + "كل %1$d أمتار" + "كل %1$d مترًا" + "كل %1$d متر" + + "رفع الصور ومقاطع الفيديو بسرعة أكبر وتقليل استخدام البيانات" + "تحسين جودة الوسائط" + "الإشراف والسلامة" + "تحسين الصور تلقائيًا لرفعها بسرعة أكبر وتقليل أحجام الملفات." + "تحسين جودة رفع الصور" + "%1$s. اضغط هنا للتغيير." + "عالية (1080p)" + "منخفضة (480p)" + "قياسية (720p)" + "جودة رفع الفيديو" + "موفّر الإشعارات الفورية" + "عطّل محرر النص المنسق لكتابة Markdown يدويًا." + "إيصالات القراءة" + "إذا كان هذا الخيار متوقفًا، فلن تُرسل إيصالات القراءة الخاصة بك إلى أي شخص. ستظل تتلقى إيصالات القراءة من المستخدمين الآخرين." + "مشاركة حالة التواجد" + "إذا كان هذا الخيار متوقفًا، فلن تتمكن من إرسال أو تلقي إيصالات القراءة أو إشعارات الكتابة." + "إخفاء دائمًا" + "إظهار دائمًا" + "في الغرف الخاصة" + "يمكن دائمًا إظهار الوسائط المخفية بالضغط عليها" + "إظهار الوسائط في المخطط الزمني" + "فعّل خيار عرض مصدر الرسالة في الخط الزمني." + "ليس لديك أي مستخدمين محظورين" + "إلغاء الحظر" + "ستتمكن من رؤية كل رسائلهم مجدّداً." + "إلغاء حظر المستخدم" + "جارٍ إلغاء الحظر…" + "الاسم" + "اسمك" + "حدث خطأ غير معروف وتعذّر تغيير المعلومات." + "غير قادر على تحديث الملف الشخصي" + "تعديل الملف الشخصي" + "يتم تحديث الملف الشخصي…" + "تفعيل الردود في السلاسل" + "سيُعاد تشغيل التطبيق لتطبيق هذا التغيير." + "جرّب أحدث أفكارنا قيد التطوير. هذه الميزات ليست نهائية؛ قد تكون غير مستقرة وقد تتغير." + "هل ترغب في التجربة؟" + "المختبرات" + "إعدادات إضافية" + "نغمة رنين المكالمات" + "المكالمات الصوتية والفيديو" + "عدم تطابق الإعدادات" + "لقد بسّطنا إعدادات الإشعارات لتكون الخيارات أسهل للإيجاد. بعض الإعدادات المخصصة التي كنت قد طبّقتها في السابق ليست ظاهرة هنا، ولكنها ما تزال مفعّلة. + +إذا ما تقدّمت، فبعض إعداداتك قد تتغير." + "المحادثات الخاصة" + "إعدادات خاصة لكل محادثة" + "حدث خطأ أثناء تحديث إعدادات الإشعارات." + "كل الرسائل" + "الإشارات والكلمات الرئيسية فقط" + "في المحادثات الخاصة، أرسل لي إشعارات لـ" + "في المحادثات الجماعية، أرسل لي إشعارات لـ" + "تفعيل الإشعارات على هذا الجهاز" + "تعذّر تصحيح الإعدادات، يُرجى المحاولة مرة أخرى." + "المحادثات الجماعية" + "الدعوات" + "لا يدعم خادمك المنزلي هذا الخيار في الغرف المشفّرة، وقد لا تتلقى إشعارات في بعض الغرف." + "الإشارات" + "اختيار صوت آخر…" + "يُستخدم حاليًا %1$s" + "صوت الرسائل" + "صوت الرسائل" + "الكل" + "الإشارات" + "أرسل لي إشعارات لـ" + "أرسل لي إشعاراً عندما يُكتب @room" + "مخصص" + "صوت مخصص…" + "خطأ في حذف الملف" + "افتراضي Element" + "Element Fade" + "خطأ في استيراد الملف" + "مشكلة في معاينة صوت التنبيه" + "الصوت" + "إغلاق خطأ صوت التنبيه" + "مشكلة في تعيين صوت التنبيه" + "صامت" + "افتراضي النظام" + "لتلقي الإشعارات يرجى تغيير الـ %1$s خاصتك" + "إعدادات النظام" + "إشعارات النظام مُطفأة" + "الإشعارات" + "أسود" + "داكن" + "فاتح" + "النظام" + "سجل الإشعارات الفورية" + "استكشاف الأخطاء وإصلاحها" + "استكشاف أخطاء الإشعارات وإصلاحها" + diff --git a/features/preferences/impl/src/main/res/values-es/translations.xml b/features/preferences/impl/src/main/res/values-es/translations.xml index dd496a1273..60ff403b41 100644 --- a/features/preferences/impl/src/main/res/values-es/translations.xml +++ b/features/preferences/impl/src/main/res/values-es/translations.xml @@ -10,9 +10,25 @@ "URL no válida, asegúrate de incluir el protocolo (http/https) y la dirección correcta." "Ocultar avatares en las invitaciones a salas" "Ocultar vistas previas de multimedia en la cronología" + "Experimental" + "La distancia que debes recorrer para que se active una actualización." + "Asegúrate de que la «Ubicación precisa» está activada para esta aplicación. Para cambiar el permiso, ve a %1$s." + "Ajustes de la aplicación" + "Actualizaciones de ubicación en tiempo real" + + "Cada %1$d metros" + "Cada %1$d metros" + "Sube fotos y vídeos más rápido y reduce el uso de datos" - "Optimizar la calidad de los medios" + "Optimizar la calidad multimedia" "Moderación y seguridad" + "Optimizar automáticamente las imágenes para subidas más rápidas y tamaños de archivo más pequeños." + "Optimizar la calidad de subida de imágenes" + "%1$s. Toca aquí para cambiar." + "Alta (1080p)" + "Baja (480p)" + "Estándar (720p)" + "Calidad de subida de vídeos" "Proveedor de notificaciones push" "Desactiva el editor de texto enriquecido para escribir Markdown manualmente." "Confirmaciones de lectura" @@ -36,7 +52,13 @@ "No se puede actualizar el perfil" "Editar perfil" "Actualizando perfil…" + "Activar respuestas en hilo" + "La aplicación se reiniciará para aplicar este cambio." + "Prueba nuestras últimas ideas en desarrollo. Estas funciones aún no están terminadas; pueden ser inestables y están sujetas a cambios." + "¿Ganas de experimentar?" + "Experimental" "Ajustes adicionales" + "Tono de llamada" "Llamadas de audio y vídeo" "La configuración no coincide" "Hemos simplificado la Configuración de Notificaciones para hacer las opciones más fáciles de encontrar. Algunas configuraciones personalizadas que has elegido en el pasado no se muestran aquí, pero siguen activas. @@ -55,14 +77,39 @@ Si continúas, es posible que algunos de tus ajustes cambien." "Invitaciones" "Tu servidor base no admite esta opción en salas cifradas, puede que no recibas notificaciones de algunas salas." "Menciones" + "Elegir otro sonido…" + "Usando actualmente %1$s" + "Sonido de mensaje" + "Sonido de mensaje" "Todos" "Menciones" "Notificarme para" "Notificarme con @room" + "Personalizado" + "Sonido personalizado…" + "Error al eliminar el archivo" + "Predeterminado de Element" + "Desvanecimiento de Element" + "Error al importar el archivo" + "Problema al previsualizar el sonido de alerta" + "Sonido" + "Ignorar el error del sonido de alerta" + "Problema al establecer el sonido de alerta" + "Silencioso" + "Predeterminado del sistema" "Para recibir notificaciones, cambia tus %1$s." "ajustes del sistema" "Notificaciones del sistema desactivadas" "Notificaciones" + "Ausente" + "Vuelvo enseguida" + "Personalizado…" + "Estado personalizado…" + "Hora de concentrarse" + "En una reunión" + "En la carretera" + "¿Cuál es tu estado?" + "Negro" "Oscuro" "Claro" "Sistema" diff --git a/features/preferences/impl/src/main/res/values-fa/translations.xml b/features/preferences/impl/src/main/res/values-fa/translations.xml index df42ca953c..167bf022ca 100644 --- a/features/preferences/impl/src/main/res/values-fa/translations.xml +++ b/features/preferences/impl/src/main/res/values-fa/translations.xml @@ -1,5 +1,6 @@ + "برای اطمینان از اینکه هرگز تماس مهمی را از دست نمی‌دهید، لطفاً تنظیمات خود را تغییر دهید تا وقتی تلفنتان قفل است، اعلان‌ها به صورت تمام صفحه نمایش داده شوند." "بهبود تجریهٔ تماستان" "گزینش چگونگی دریافت آگاهی" "حالت توسعه‌دهنده" @@ -10,8 +11,20 @@ "نهفتن چهرک‌ها در درخواست‌های دعوت اتاق" "نهفتن رسانه در خط زمانی" "آزمایشگاه‌ها" + "مسافتی که باید طی کنید تا به‌روزرسانی انجام شود." + "مطمئن شوید که «موقعیت مکانی دقیق» برای این برنامه فعال شده است. برای تغییر مجوز به %1$s بروید." + "تنظیمات برنامه" + "به‌روزرسانی‌های مکان زنده" + + "هر کنتور %1$d" + "هر کنتور %1$d" + + "عکس‌ها و ویدیوها را سریع‌تر آپلود کنید و مصرف اینترنت را کاهش دهید" "بهینه سازی کیفیت رسانه" "نظارت و امنیت" + "بهینه‌سازی خودکار تصاویر برای آپلود سریع‌تر و حجم کمتر فایل‌ها." + "بهینه سازی کیفیت آپلود تصاویر" + "%1$s. برای تغییر، اینجا را لمس کنید." "زیاد (۱۰۸۰ت)" "کم (۴۸۰ت)" "استاندارد (۷۲۰ت)" @@ -39,8 +52,13 @@ "ناتوان در به‌روز کردن نمایه" "ویرایش نمایه" "به‌روز کردن نمایه…" + "فعال کردن پاسخ‌های تاپیک" + "برنامه برای اعمال این تغییر مجدداً راه‌اندازی خواهد شد." + "آخرین ایده‌های در حال توسعه ما را امتحان کنید. این ویژگی‌ها هنوز نهایی نشده‌اند؛ ممکن است ناپایدار باشند، ممکن است تغییر کنند." + "احساس تجربی دارید؟" "آزمایشگاه‌ها" "تنظیمات اضافی" + "آهنگ زنگ تماس" "تماس‌های صوتی و تصویری" "نامتطابقت در پیکربندی" "تنظمیات آگاهی را ساده کرده‌ایم تا یافتن انتخاب‌ها را ساده‌تر کنیم. برهی تنظمیات سفارسی که در گذشته گزیده‌اید این‌جا نشان داده نمی‌شوند؛ ولی همچنن فعّالند. @@ -59,14 +77,39 @@ "دعوت‌ها" "کارساز خانگیتان از این گزینه در اتاق‌های رمز شده پشتیبانی نمی‌کند. ممکن است در برخی اتاق‌ها آگاه نشوید." "اشاره‌ها" + "صدای دیگری انتخاب کنید…" + "در حال حاضر از %1$s استفاده می‌شود." + "صدای پیام" + "صدای پیام" "همه" "اشاره‌ها" "آگاه کردنم برای" "آگاه کردنم برای ‪@room" + "سفارشی" + "صدای سفارشی…" + "خطا در حذف فایل" + "Element پیش‌فرض" + "Element فید" + "خطا در وارد کردن فایل" + "مشکل در پیش‌نمایش صدای هشدار" + "صدا" + "خطای صدای هشدار را رد کنید" + "مشکل در تنظیم صدای هشدار" + "بی‌صدا" + "پیش‌فرض سیستم" "برای گرفتن آگاهی‌ها لطفاً%1$sتان را تغییر دهید." "تنظیمات سامانه" "آگاهی‌های سامانه‌ای خاموش شدند" "آگاهی‌ها" + "بعدا" + "همین الان برگرد" + "سفارشی…" + "وضعیت سفارشی…" + "زمان تمرکز" + "در یک جلسه" + "در جاده" + "وضعیتت چطوره؟" + "سیاه" "تیره" "روشن" "سامانه" diff --git a/features/preferences/impl/src/main/res/values-fi/translations.xml b/features/preferences/impl/src/main/res/values-fi/translations.xml index f37673427f..c50989e526 100644 --- a/features/preferences/impl/src/main/res/values-fi/translations.xml +++ b/features/preferences/impl/src/main/res/values-fi/translations.xml @@ -68,7 +68,7 @@ Jos jatkat, jotkin asetukset saattavat muuttua." "Keskustelukohtaiset asetukset" "Ilmoitusasetusten muokkaamisessa tapahtui virhe." "Kaikista viesteistä" - "Vain maininnoista ja avainsanoista" + "Vain maininnoista ja vastauksista" "Yksityiskeskusteluissa, ilmoita minulle" "Ryhmäkeskusteluissa, ilmoita minulle" "Ota ilmoitukset käyttöön tällä laitteella" @@ -101,6 +101,14 @@ Jos jatkat, jotkin asetukset saattavat muuttua." "järjestelmäsi asetuksia" "Järjestelmän ilmoitukset on poissa päältä" "Ilmoitukset" + "Poissa" + "Palaan pian" + "Mukautettu…" + "Mukautettu tila…" + "Keskittymisaika" + "Kokouksessa" + "Tien päällä" + "Mikä on tilasi?" "Musta" "Tumma" "Vaalea" diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml index fc57491f4a..06fe3be814 100644 --- a/features/preferences/impl/src/main/res/values-fr/translations.xml +++ b/features/preferences/impl/src/main/res/values-fr/translations.xml @@ -101,6 +101,14 @@ Si vous continuez, il est possible que certains de vos paramètres soient modifi "paramètres du système" "Les notifications du système sont désactivées" "Notifications" + "Absent" + "Je reviens tout de suite" + "Personnalisé…" + "Statut personnalisé…" + "Concentré" + "En réunion" + "Sur la route" + "Quel est votre statut ?" "Noir" "Sombre" "Clair" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index bb688f498d..3b2645783f 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -23,12 +23,14 @@ import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.data.megaBytes +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -108,10 +110,8 @@ class DeveloperSettingsPresenterTest { fun `present - enterprise build can change the brand color`() = runTest { val overrideBrandColorResult = lambdaRecorder { _, _ -> } val presenter = createDeveloperSettingsPresenter( - enterpriseService = FakeEnterpriseService( - isEnterpriseBuild = true, - overrideBrandColorResult = overrideBrandColorResult, - ) + enterpriseService = FakeEnterpriseService(overrideBrandColorResult = overrideBrandColorResult), + buildMeta = aBuildMeta(isEnterpriseBuild = true), ) presenter.test { skipItems(1) @@ -179,6 +179,7 @@ class DeveloperSettingsPresenterTest { vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {}, databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), + buildMeta: BuildMeta = aBuildMeta(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() }, @@ -191,6 +192,7 @@ class DeveloperSettingsPresenterTest { databaseSizesUseCase = databaseSizesUseCase, fileSizeFormatter = FakeFileSizeFormatter(), markAllRoomsAsRead = markAllRoomsAsRead, + buildMeta = buildMeta, ) } } diff --git a/features/rageshake/api/src/main/res/values-ar/translations.xml b/features/rageshake/api/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..13f99159db --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ar/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s أُغلِق قسرياً في آخر مرة استخدمه. أتودّ مشاركة بلاغ إغلاق قسري معنا؟" + "يظهر أنك تهز الهاتز بسرعة. أتودّ فتح شاشة الإبلاغ عن عطل؟" + "رج الهاتف للإبلاغ عن خطأ" + "عتبة الكشف" + diff --git a/features/rageshake/impl/src/main/res/values-ar/translations.xml b/features/rageshake/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..5fd8157467 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,22 @@ + + + "إرفاق لقطة شاشة" + "لا مانع من التواصل معي إذا كان عندكم أي أسئلة إضافية." + "تواصل معي" + "تعديل لقطة الشاشة" + "صِف مشكلتك رجاءً. ما الذي قمت به؟ ما كنت تتوقع أن يحصل؟ وما الذي حصل فعلياً. زوّدنا بأكبر قدر من المعلومات رجاءً." + "صِف المشكلة…" + "إن كان ممكناً فاكتب الوصف بالإنجليزية." + "الوصف قصير جدًا، يُرجى تقديم مزيد من التفاصيل حول ما حدث. شكرًا!" + "يمكنك إدخال رقم مشكلة مرتبطة على GitHub، إن وجدت." + "مشكلة على GitHub" + "أرسل الـcrash logs" + "السماح بالـLogs" + "سجلاتك كبيرة جدًا، لذلك لا يمكن تضمينها في هذا البلاغ. يرجى إرسالها إلينا بطريقة أخرى." + "إرسال لقطة شاشة" + "سيتمّ تضمين السجلّات (logs) مع رسالتك لنتأكد من أن كل شيء يشتغل كما يجب. لكي ترسل الرسالة بلا سجلّات فأطفئ هذا الخيار." + "%1$s أُغلِق قسرياً في آخر مرة استخدمه. أتودّ مشاركة بلاغ إغلاق قسري معنا؟" + "إذا كنت تواجه مشكلات في الإشعارات، يمكن أن يساعد رفع قواعد الإشعارات الفورية في تحديد السبب الجذري. لاحظ أن هذه القواعد قد تحتوي على معلومات خاصة، مثل اسم العرض الخاص بك أو الكلمات المفتاحية التي تريد تلقي إشعارات عنها." + "إرسال إعدادات الإشعارات" + "عرض السجلات" + diff --git a/features/rageshake/impl/src/main/res/values-es/translations.xml b/features/rageshake/impl/src/main/res/values-es/translations.xml index 5c21e4a9bd..5841d8b29b 100644 --- a/features/rageshake/impl/src/main/res/values-es/translations.xml +++ b/features/rageshake/impl/src/main/res/values-es/translations.xml @@ -8,11 +8,15 @@ "Describe el problema…" "Si es posible, escribe la descripción en inglés." "La descripción es demasiado corta. Proporciona más detalles sobre lo sucedido. ¡Gracias!" + "Puedes introducir el número de una incidencia de GitHub asociada, si la hubiere." + "Incidencia de GitHub" "Enviar registros de fallos" "Permitir registros" "Tus registros de errores son demasiado grandes y no pueden añadirse a este informe, por favor envíanoslos de otra manera." "Enviar captura de pantalla" "Los registros se incluirán con tu mensaje para asegurarse de que todo funciona correctamente. Para enviar tu mensaje sin registros, desactiva esta opción." "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" + "Si tienes problemas con las notificaciones, enviar las reglas de notificaciones push puede ayudarnos a identificar la causa raíz. Ten en cuenta que estas reglas pueden contener información privada, como tu nombre público o las palabras clave para las que quieres recibir notificaciones." + "Enviar ajustes de notificación" "Ver los registros" diff --git a/features/rageshake/impl/src/main/res/values-fa/translations.xml b/features/rageshake/impl/src/main/res/values-fa/translations.xml index 2e27f97ba6..85d07c41cd 100644 --- a/features/rageshake/impl/src/main/res/values-fa/translations.xml +++ b/features/rageshake/impl/src/main/res/values-fa/translations.xml @@ -8,10 +8,15 @@ "شرح مشکل…" "ترجیحاً توضیحات را به زبان انگلیسی بنویسید." "توضیحات خیلی کوتاه است، لطفاً جزئیات بیشتری در مورد آنچه اتفاق افتاده ارائه دهید. متشکرم!" + "در صورت وجود، می‌توانید شماره مشکل مرتبط با GitHub را وارد کنید." + "مسئله GitHub" "ارسال رخدادنگارهای خطا" "اجازه به گزارش‌ها" + "گزارش‌های شما بیش از حد بزرگ هستند، بنابراین نمی‌توان آنها را در این گزارش گنجاند، لطفاً آنها را از طریق دیگری برای ما ارسال کنید." "ارسال تصویر صفحه" "برای اطمینان از درست کار کردن همه‌چیز گزارش‌ها در پیامتان قرار خواهد گرفت. برای فرستادن پیام بدون گزارش‌ها این تنظیم را خاموش کنید." "%1$sآخرین باری که استفاده شد، از کار افتاد. آیا مایلید گزارش خرابی را با ما به اشتراک بگذارید؟" + "اگر با اعلان‌ها مشکل دارید، آپلود قوانین ارسال اعلان می‌تواند به ما در شناسایی علت اصلی کمک کند. توجه داشته باشید که این قوانین می‌توانند حاوی اطلاعات خصوصی مانند نام نمایشی یا کلمات کلیدی شما باشند که باید برای آنها اعلان دریافت کنید." + "تنظیمات ارسال اعلان" "دیدن گزارش‌ها" diff --git a/features/reportroom/impl/src/main/res/values-ar/translations.xml b/features/reportroom/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..1068833e9b --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,8 @@ + + + "تم إرسال بلاغك بنجاح، لكننا واجهنا مشكلة أثناء محاولة مغادرة الغرفة. يرجى المحاولة مرة أخرى." + "تعذّرت مغادرة الغرفة" + "أبلغ مسؤولك عن هذه الغرفة. إذا كانت الرسائل مشفّرة، فلن يتمكن مسؤولك من قراءتها." + "صِف سبب الإبلاغ…" + "الإبلاغ عن الغرفة" + diff --git a/features/reportroom/impl/src/main/res/values-fa/translations.xml b/features/reportroom/impl/src/main/res/values-fa/translations.xml index 9930e9ceac..a75095ff90 100644 --- a/features/reportroom/impl/src/main/res/values-fa/translations.xml +++ b/features/reportroom/impl/src/main/res/values-fa/translations.xml @@ -1,6 +1,8 @@ + "گزارش شما با موفقیت ارسال شد، اما هنگام تلاش برای خروج از اتاق با مشکلی مواجه شدیم. لطفاً دوباره امتحان کنید." "ناتوان از ترک اتاق" + "این اتاق را به مدیر خود گزارش دهید. اگر پیام‌ها رمزگذاری شده باشند، مدیر شما قادر به خواندن آنها نخواهد بود." "شرح دلیل گزارش…" "گزارش اتاق" diff --git a/features/rolesandpermissions/impl/src/main/res/values-ar/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..75633a1273 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,98 @@ + + + "مسؤول" + "حظر الأشخاص" + "تغيير الإعدادات" + "إزالة الرسائل" + "عضو" + "دعوة الأشخاص" + "مشاركة الموقع المباشر" + "إدارة المساحة" + "إدارة الغرف" + "إدارة الأعضاء" + "الرسائل والمحتوى" + "مشرف" + "إزالة الأشخاص" + "تغيير الصورة الرمزية" + "تعديل التفاصيل" + "تغيير الاسم" + "تغيير الموضوع" + "إرسال الرسائل" + "الصلاحيات" + "تعديل المسؤولين" + "لن تتمكن من التراجع عن هذا الإجراء. ستتم ترقية المستخدم ليحصل على مستوى الصلاحيات نفسه الذي لديك." + "إضافة مسؤول؟" + "لن تتمكن من التراجع عن هذا الإجراء. ستنقل الملكية إلى المستخدمين المحددين. بمجرد مغادرتك، سيصبح ذلك دائمًا." + "نقل الملكية؟" + "خفض الرتبة" + "لن تتمكن من التراجع عن هذا التغيير لأنك تخفض رتبتك. إذا كنت آخر مستخدم لديه صلاحيات في الغرفة، فسيكون من المستحيل استعادة الصلاحيات." + "خفض رتبتك؟" + "%1$s (قيد الانتظار)" + "(قيد الانتظار)" + "يتمتع المسؤولون تلقائيًا بصلاحيات المشرفين" + "يتمتع المالكون بامتيازات المسؤولين تلقائيًا." + "تعديل المشرفين" + "اختيار المالكين" + "المسؤولون" + "المشرفون" + "الأعضاء" + "لديك تغييرات غير محفوظة." + "حفظ التغييرات؟" + "لا يوجد مستخدمون محظورون." + + "%1$d محظورين" + "%1$d محظور" + "%1$d محظوران" + "%1$d محظورين" + "%1$d محظورًا" + "%1$d محظور" + + "تحقّق من الإملاء أو جرّب بحثًا جديدًا" + "لا توجد نتائج لـ “%1$s”" + + "%1$d شخص" + "%1$d شخص" + "%1$d شخصين" + "%1$d اشخاص" + "%1$d شخص" + "%1$d شخص" + + "حظر المستخدم" + "إزالة العضو فقط" + "إلغاء الحظر" + "يمكن لهذا المستخدم الانضمام إلى هذه الغرفة مرة أخرى عند دعوته." + "إلغاء حظر المستخدم" + "المحظورون" + "الأعضاء" + + "%1$d مدعوّين" + "%1$d مدعو" + "%1$d مدعوّان" + "%1$d مدعوّين" + "%1$d مدعوًا" + "%1$d مدعو" + + "قيد الانتظار" + "مسؤول" + "مشرف" + "مالك" + "أعضاء الغرفة" + "جارٍ رفع حظر %1$s" + "المسؤولون" + "المسؤولون والمالكون" + "تغيير دوري" + "خفض رتبتي إلى عضو" + "خفض رتبتي إلى مشرف" + "الإشراف على الأعضاء" + "الرسائل والمحتوى" + "المشرفون" + "المالكون" + "الصلاحيات" + "إعادة تعيين الصلاحيات" + "بمجرد إعادة تعيين الصلاحيات، ستفقد الإعدادات الحالية." + "إعادة تعيين الصلاحيات؟" + "الأدوار" + "تفاصيل الغرفة" + "تفاصيل المساحة" + "الأدوار والصلاحيات" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-es/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-es/translations.xml index a41489ea1b..defb25b9be 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-es/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-es/translations.xml @@ -2,9 +2,13 @@ "Administrador" "Vetar personas" + "Cambiar ajustes" "Eliminar mensajes" "Miembro" "Invitar a otras personas" + "Compartir ubicación en tiempo real" + "Gestionar el espacio" + "Gestionar salas" "Administrar miembros" "Mensajes y contenido" "Moderador" @@ -14,25 +18,36 @@ "Cambiar el nombre de la sala" "Cambiar el tema de la sala" "Enviar mensajes" + "Permisos" "Editar administradores" "No podrás deshacer esta acción. Estás promocionando al usuario para que tenga el mismo nivel de poder que tú." - "¿Agregar Admin?" + "¿Agregar administrador?" + "Esta acción es irreversible. Estás transfiriendo la propiedad a los usuarios seleccionados. Una vez que salgas, será permanente." + "¿Transferir la propiedad?" "Degradar" "No podrás deshacer este cambio ya que te estás degradando. Si eres el último usuario privilegiado en la sala será imposible recuperar los privilegios." "¿Degradarte?" "%1$s (Pendiente)" "(Pendiente)" "Los administradores tienen privilegios de moderador de forma automática" + "Los propietarios tienen automáticamente privilegios de administrador." "Editar moderadores" + "Elegir propietarios" "Administradores" "Moderadores" "Miembros" "Tienes cambios sin guardar." "¿Guardar cambios?" - "No hay usuarios baneados." + "No hay usuarios vetados." + + "%1$d vetado" + "%1$d vetados" + + "Revisa la ortografía o intenta una nueva búsqueda." + "Ningún resultado para «%1$s»" - "%1$d Persona" - "%1$d Personas" + "%1$d persona" + "%1$d personas" "Sacar y vetar a un miembro" "Solo eliminar miembro" @@ -41,21 +56,31 @@ "Eliminar veto en la sala" "Vetados" "Miembros" + + "%1$d invitado" + "%1$d invitados" + + "Pendiente" "Administrador" "Moderador" + "Propietario" "Miembros de la sala" "Levantando veto a %1$s" "Administradores" + "Administradores y propietarios" "Cambiar mi rol" "Degradar a miembro" "Degradar a moderador" "Moderación de miembros" "Mensajes y contenido" "Moderadores" + "Propietarios" + "Permisos" "Restablecer permisos" "Una vez que restablezca los permisos, perderá la configuración actual." "¿Restablecer los permisos?" "Roles" "Detalles de la sala" + "Detalles del espacio" "Roles y permisos" diff --git a/features/rolesandpermissions/impl/src/main/res/values-fa/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fa/translations.xml index e381cec856..345a451678 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-fa/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-fa/translations.xml @@ -2,9 +2,13 @@ "ادمین" "تحریم افراد" + "تغییر تنظیمات" "حذف پیام‌ها" "عضو" "دعوت کاربران" + "اشتراک گذاری موقعیت مکانی زنده" + "مدیریت فضا" + "مدیریت اتاق‌ها" "مدیریت اعضا" "پیام‌ها و محتوا" "ناظم" @@ -14,9 +18,11 @@ "تغییر نام اتاق" "دگرگونی موضوع اتاق" "فرستادن پیام‌ها" + "دسترسی‌ها" "ویرایش مدیران" "قادر نخواهید بود این کنش را بازکردانید. داردید کاربر را به سطح قدرت خودتان ارتقا می‌دهید." "افزودن مدیر؟" + "شما قادر به لغو این اقدام نخواهید بود. شما در حال انتقال مالکیت به کاربران انتخاب شده هستید. پس از خروج، این انتقال دائمی خواهد بود." "انتقال مالکیت؟" "تنزل بده" "شما نمی‌توانید این تغییر را بازگردانید زیرا در حال تنزل نقش خود در اتاق هستید، اگر آخرین کاربر ممتاز در اتاق باشید، امکان دستیابی مجدد به دسترسی‌های سطح بالای اتاق غیرممکن است." @@ -33,6 +39,12 @@ "تغییراتی ذخیره نشده دارید." "ذخیرهٔ تغییرات؟" "هیچ کاربری محروم نشده." + + "%1$d محروم شد" + "%1$d محروم شد" + + "املای کلمات را بررسی کنید یا جستجوی جدیدی را امتحان کنید" + "هیچ نتیجه‌ای برای «%1$s» یافت نشد." "%1$d نفر" "%1$d نفر" @@ -44,6 +56,11 @@ "تحریم نکردن از اتاق" "محروم" "اعضا" + + "%1$d دعوت شده" + "%1$d دعوت شده" + + "در حال بررسی" "ادمین" "ناظم" "مالک" @@ -58,10 +75,12 @@ "پیام‌ها و محتوا" "ناظم‌ها" "مالکان" + "دسترسی‌ها" "بازنشانی اجازه‌ها" "پس از بازنشانی اجازه‌ها تنظیمات فعلی را از دست خواهید داد." "بازنشانی اجازه‌ها؟" "نقش‌ها" "جزییات اتاق" + "جزئیات فضا" "نقش‌ها و مجوزها" diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt index 78be168e53..1ec0ce0691 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt @@ -24,7 +24,10 @@ import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeoutException import kotlin.jvm.optionals.getOrElse +import kotlin.time.Duration.Companion.seconds @AssistedInject class RoomAliasResolverPresenter( @@ -61,9 +64,14 @@ class RoomAliasResolverPresenter( private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch { suspend { - matrixClient.resolveRoomAlias(roomAlias) + val result = withTimeoutOrNull(ALIAS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) { + matrixClient.resolveRoomAlias(roomAlias) + } ?: throw TimeoutException("Timed out while resolving ${roomAlias.value}") + result .getOrThrow() .getOrElse { throw RoomAliasResolverFailures.UnknownAlias } }.runCatchingUpdatingState(resolveState) } } + +private const val ALIAS_RESOLVE_TIMEOUT_IN_SECONDS = 10 diff --git a/features/roomaliasresolver/impl/src/main/res/values-ar/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..6500dcebf6 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,5 @@ + + + "تعذّر عرض معاينة هذه الغرفة" + "تعذّر حل الاسم المستعار للغرفة." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-fa/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-fa/translations.xml index 4fba81c4f8..f9fdf67b21 100644 --- a/features/roomaliasresolver/impl/src/main/res/values-fa/translations.xml +++ b/features/roomaliasresolver/impl/src/main/res/values-fa/translations.xml @@ -1,4 +1,5 @@ + "ما نتوانستیم پیش‌نمایش این اتاق را نمایش دهیم" "شکست در دریافت نام مستعار اتاق." diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt index df8b8058be..a50049446b 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt @@ -22,10 +22,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SERVER_LIST import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import java.util.Optional +import java.util.concurrent.TimeoutException class RoomAliasHelperPresenterTest { @get:Rule @@ -60,6 +62,22 @@ class RoomAliasHelperPresenterTest { } } + @Test + fun `present - a server that never answers ends up in an error state`() = runTest { + val client = FakeMatrixClient( + resolveRoomAliasResult = { awaitCancellation() } + ) + val presenter = createPresenter(matrixClient = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().resolveState.isUninitialized()).isTrue() + assertThat(awaitItem().resolveState.isLoading()).isTrue() + val resultState = awaitItem() + assertThat(resultState.resolveState.errorOrNull()).isInstanceOf(TimeoutException::class.java) + } + } + @Test fun `present - resolve alias error and retry`() = runTest { val client = FakeMatrixClient( diff --git a/features/roomdetails/impl/src/main/res/values-ar/translations.xml b/features/roomdetails/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..3d640a615a --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,188 @@ + + + "لا يرى الأعضاء الجدد السجل" + "يرى الأعضاء الجدد السجل" + "يمكن لأي شخص رؤية السجل" + "تحتاج إلى عنوان حتى تظهر الغرفة في الدليل العام." + "تعديل العنوان" + "حدث خطأ أثناء تحديث إعدادات الإشعارات." + "لا يدعم خادمك المنزلي هذا الخيار في الغرف المشفّرة، وقد لا تتلقى إشعارات في بعض الغرف." + "الاستطلاعات" + "مسؤول" + "حظر الأشخاص" + "إزالة الرسائل" + "عضو" + "دعوة الأشخاص" + "إدارة الأعضاء" + "الرسائل والمحتوى" + "مشرف" + "إزالة الأشخاص" + "تغيير الصورة الرمزية" + "تعديل التفاصيل" + "تغيير الاسم" + "تغيير الموضوع" + "إرسال الرسائل" + "تعديل المسؤولين" + "لن تتمكن من التراجع عن هذا الإجراء. ستتم ترقية المستخدم ليحصل على مستوى الصلاحيات نفسه الذي لديك." + "إضافة مسؤول؟" + "لن تتمكن من التراجع عن هذا الإجراء. ستنقل الملكية إلى المستخدمين المحددين. بمجرد مغادرتك، سيصبح ذلك دائمًا." + "نقل الملكية؟" + "خفض الرتبة" + "لن تتمكن من التراجع عن هذا التغيير لأنك تخفض رتبتك. إذا كنت آخر مستخدم لديه صلاحيات في الغرفة، فسيكون من المستحيل استعادة الصلاحيات." + "خفض رتبتك؟" + "%1$s (قيد الانتظار)" + "(قيد الانتظار)" + "يتمتع المسؤولون تلقائيًا بصلاحيات المشرفين" + "يتمتع المالكون بامتيازات المسؤولين تلقائيًا." + "تعديل المشرفين" + "اختيار المالكين" + "المسؤولون" + "المشرفون" + "الأعضاء" + "لديك تغييرات غير محفوظة." + "حفظ التغييرات؟" + "إضافة موضوع" + "مشفّرة" + "غير مشفّرة" + "غرفة عامة" + "تعديل التفاصيل" + "قد كان هناك خطأ والمعلومات لم تتحدث." + "غير قادر على تعديل الغرفة" + "الرسائل مؤمَّنة بأقفال. أنت والمستقبلون فقط من تملكون المفاتيح الخاصة لفتح أقفالها." + "تشفير الرسائل مفعّل" + "حصل خطأ أثناء تحميل إعدادات الإشعارات." + "تعذّر كتم هذه الغرفة، يرجى المحاولة مجدداً." + "تعذّر إلغاء كتم هذه الغرفة، يرجى المحاولة مجدداً." + "لا تغلق التطبيق حتى الانتهاء." + "جارٍ تحضير الدعوات…" + "دعوة أشخاص" + "دعوة " + "مغادرة المحادثة" + "مغادرة الغرفة" + "الوسائط والملفات" + "مخصص" + "افتراضي" + "الإشعارات" + "الرسائل المثبّتة" + "الملف الشخصي" + "طلبات الانضمام" + "الأدوار والصلاحيات" + "الاسم" + "الأمان والخصوصية" + "الأمان" + "مشاركة الغرفة" + "معلومات الغرفة" + "الموضوع" + "يتم تحديث الغرفة…" + "لا يوجد مستخدمون محظورون." + + "%1$d محظورين" + "%1$d محظور" + "%1$d محظوران" + "%1$d محظورين" + "%1$d محظورًا" + "%1$d محظور" + + "تحقّق من الإملاء أو جرّب بحثًا جديدًا" + "لا توجد نتائج لـ “%1$s”" + + "%1$d شخص" + "%1$d شخص" + "%1$d شخصين" + "%1$d اشخاص" + "%1$d شخص" + "%1$d شخص" + + "حظر المستخدم" + "إزالة العضو فقط" + "إلغاء الحظر" + "يمكن لهذا المستخدم الانضمام إلى هذه الغرفة مرة أخرى عند دعوته." + "إلغاء حظر المستخدم" + "المحظورون" + "الأعضاء" + + "%1$d مدعوّين" + "%1$d مدعو" + "%1$d مدعوّان" + "%1$d مدعوّين" + "%1$d مدعوًا" + "%1$d مدعو" + + "قيد الانتظار" + "مسؤول" + "مشرف" + "مالك" + "أعضاء الغرفة" + "جارٍ رفع حظر %1$s" + "السماح بإعدادات مخصصة" + "سيؤدي تشغيل هذا إلى استبدال إعداداتك الافتراضية" + "أرسل لي إشعارات هذه المحادثة لـ" + "يمكنك تغيير ذلك في الـ %1$s الخاص بك." + "الإعدادات العامة" + "الإعدادات الافتراضية" + "إزالة الإعدادات الخاصة" + "حصل خطأ أثناء تحميل إعدادات الإشعارات." + "تعّذرت استعادة الوضع الافتراضي، يرجى المحاولة مجدداً." + "تعذّر تطبيق الوضع، يرجى المحاولة مجدداً." + "لا يدعم خادمك المنزلي هذا الخيار في الغرف المشفّرة، ولن تتلقى إشعارات في هذه الغرفة." + "كل الرسائل" + "في هذه الغرفة، أرسل لي إشعارات لـ" + "المسؤولون" + "المسؤولون والمالكون" + "تغيير دوري" + "خفض رتبتي إلى عضو" + "خفض رتبتي إلى مشرف" + "الإشراف على الأعضاء" + "الرسائل والمحتوى" + "المشرفون" + "المالكون" + "الصلاحيات" + "إعادة تعيين الصلاحيات" + "بمجرد إعادة تعيين الصلاحيات، ستفقد الإعدادات الحالية." + "إعادة تعيين الصلاحيات؟" + "الأدوار" + "تفاصيل الغرفة" + "الأدوار والصلاحيات" + "وضع علامة كمقروء" + "وضع علامة كغير مقروء" + "إضافة عنوان" + "يمكن لأي شخص في المساحات المصرّح بها الانضمام، لكن يجب على الآخرين طلب الوصول." + "يجب على الجميع طلب الوصول." + "طلب الانضمام" + "يمكن لأي شخص في %1$s الانضمام، لكن يجب على الآخرين طلب الوصول." + "نعم، فعّل التشفير" + "بعد تفعيله، لا يمكن تعطيل التشفير في الغرفة. لن يكون سجل الرسائل مرئيًا لأعضاء الغرفة إلا منذ وقت دعوتهم أو منذ انضمامهم إلى الغرفة. +لن يتمكن أي شخص غير أعضاء الغرفة من قراءة الرسائل. قد يمنع ذلك البوتات والجسور من العمل بشكل صحيح. +لا نوصي بتفعيل التشفير للغرف التي يمكن لأي شخص العثور عليها والانضمام إليها." + "تفعيل التشفير؟" + "بعد تفعيله، لا يمكن تعطيل التشفير." + "التشفير" + "تفعيل التشفير من طرف إلى طرف" + "يمكن لأي شخص الانضمام." + "أي شخص" + "اختر المساحات التي يمكن لأعضائها الانضمام إلى هذه الغرفة دون دعوة. %1$s" + "إدارة المساحات" + "يمكن للأشخاص المدعوين فقط الانضمام." + "بالدعوة فقط" + "الوصول" + "يمكن لأي شخص في المساحات المصرّح بها الانضمام." + "يمكن لأي شخص في %1$s الانضمام." + "أعضاء المساحة" + "المساحات غير مدعومة حاليًا" + "تحتاج إلى عنوان حتى تظهر الغرفة في الدليل العام." + "العنوان" + "السماح بالعثور على هذه الغرفة عند البحث في دليل الغرف العام على %1$s" + "السماح بالعثور عليها عند البحث في الدليل العام." + "مرئية في الدليل العام" + "أي شخص (السجل عام)" + "لن تؤثر التغييرات على الرسائل السابقة، بل على الرسائل الجديدة فقط. %1$s" + "من يمكنه قراءة السجل" + "الأعضاء منذ دعوتهم" + "الأعضاء (السجل الكامل)" + "عناوين الغرف وسيلة للعثور على الغرف والوصول إليها. كما يضمن ذلك إمكانية مشاركة غرفتك بسهولة مع الآخرين. +يمكنك اختيار نشر غرفتك في دليل الغرف العام على خادمك المنزلي." + "نشر الغرفة" + "العناوين وسيلة للعثور على الغرف والمساحات والوصول إليها. كما يضمن ذلك إمكانية مشاركتها بسهولة مع الآخرين." + "الظهور" + "الأمان والخصوصية" + diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml index 2b0b59e359..130912651c 100644 --- a/features/roomdetails/impl/src/main/res/values-be/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml @@ -88,7 +88,6 @@ "Не ўдалося наладзіць рэжым, паспрабуйце яшчэ раз." "Ваш хатні сервер не падтрымлівае гэту опцыю ў зашыфраваных пакоях, вы не атрымаеце апавяшчэнне ў гэтым пакоі." "Усе паведамленні" - "Толькі згадванні і ключавыя словы" "У гэтым пакоі паведаміце мяне пра" "Адміністратары" "Змяніць маю роль" diff --git a/features/roomdetails/impl/src/main/res/values-bg/translations.xml b/features/roomdetails/impl/src/main/res/values-bg/translations.xml index 7369d78f1d..a7594bc411 100644 --- a/features/roomdetails/impl/src/main/res/values-bg/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-bg/translations.xml @@ -68,7 +68,6 @@ "Неуспешно възстановяване на режима по подразбиране, моля, опитайте отново." "Неуспешно задаване на режима, моля, опитайте отново." "Всички съобщения" - "Само споменавания и ключови думи" "В тази стая, да бъда известяван за" "Администратори" "Промяна на моята роля" diff --git a/features/roomdetails/impl/src/main/res/values-ca/translations.xml b/features/roomdetails/impl/src/main/res/values-ca/translations.xml index 8b9f8d937e..b1a022a17d 100644 --- a/features/roomdetails/impl/src/main/res/values-ca/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ca/translations.xml @@ -91,8 +91,6 @@ "No s\'ha pogut restaurar el mode predeterminat. Torna-ho a provar." "No s\'ha pogut configurar el mode. Torna-ho a provar." "El servidor no admet aquesta opció en sales xifrades, no rebras notificacions en aquesta sala." - "Tots els missatges" - "Mencions y paraules clau (només)" "En aquesta sala, notifica\'m en" "Administradors" "Canvia el meu rol" diff --git a/features/roomdetails/impl/src/main/res/values-cy/translations.xml b/features/roomdetails/impl/src/main/res/values-cy/translations.xml index f5d4eecac5..68c53ec260 100644 --- a/features/roomdetails/impl/src/main/res/values-cy/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-cy/translations.xml @@ -103,7 +103,7 @@ "Wedi methu gosod y modd, ceisiwch eto." "Nid yw eich gweinydd cartref yn cefnogi\'r dewis hwn mewn ystafelloedd sydd wedi\'u hamgryptio, chewch chi ddim eich hysbysu yn yr ystafell hon." "Pob neges" - "Crybwylliadau ac Allweddeiriau\'n unig" + "Dim ond Crybwylliadau ac Allweddeiriau" "Yn yr ystafell hon, rhowch wybod i mi am" "Gweinyddwyr" "Gweinyddwyr a pherchnogion" diff --git a/features/roomdetails/impl/src/main/res/values-da/translations.xml b/features/roomdetails/impl/src/main/res/values-da/translations.xml index 191df9f059..e727221b74 100644 --- a/features/roomdetails/impl/src/main/res/values-da/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-da/translations.xml @@ -113,8 +113,6 @@ "Gendannelse af standardtilstanden mislykkedes. Prøv igen." "Kunne ikke indstille tilstanden. Prøv igen." "Din hjemmeserver understøtter ikke denne indstilling i krypterede rum, du får ikke besked i dette rum." - "Alle beskeder" - "Kun omtaler og nøgleord" "Giv mig besked i dette rum for" "Administratorer" "Administratorer og ejere" diff --git a/features/roomdetails/impl/src/main/res/values-el/translations.xml b/features/roomdetails/impl/src/main/res/values-el/translations.xml index 5f1a3ce4ab..6350d5de24 100644 --- a/features/roomdetails/impl/src/main/res/values-el/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml @@ -114,7 +114,6 @@ "Αποτυχία ρύθμισης της λειτουργίας, δοκίμασε ξανά." "Ο αρχικός διακομιστής σας δεν υποστηρίζει αυτή την επιλογή σε κρυπτογραφημένες αίθουσες, δεν θα λάβετε ειδοποιήσεις σε αυτή την αίθουσα." "Όλα τα μηνύματα" - "Μόνο αναφορές και λέξεις-κλειδιά" "Σε αυτήν την αίθουσα, ειδοποιήστε με για" "Διαχειριστές" "Διαχειριστές και κάτοχοι" diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml index 9537596d4e..8b9b5365c8 100644 --- a/features/roomdetails/impl/src/main/res/values-es/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -1,5 +1,8 @@ + "Los miembros nuevos no ven el historial" + "Los miembros nuevos ven el historial" + "Cualquiera puede ver el historial" "Necesitarás una dirección de sala para que sea visible en el directorio." "Editar dirección" "Se ha producido un error al actualizar la configuración de notificaciones." @@ -21,14 +24,18 @@ "Enviar mensajes" "Editar administradores" "No podrás deshacer esta acción. Estás promocionando al usuario para que tenga el mismo nivel de poder que tú." - "¿Agregar Admin?" + "¿Agregar administrador?" + "Esta acción es irreversible. Estás transfiriendo la propiedad a los usuarios seleccionados. Una vez que salgas, será permanente." + "¿Transferir la propiedad?" "Degradar" "No podrás deshacer este cambio ya que te estás degradando. Si eres el último usuario privilegiado en la sala será imposible recuperar los privilegios." "¿Degradarte?" "%1$s (Pendiente)" "(Pendiente)" "Los administradores tienen privilegios de moderador de forma automática" + "Los propietarios tienen automáticamente privilegios de administrador." "Editar moderadores" + "Elegir propietarios" "Administradores" "Moderadores" "Miembros" @@ -46,11 +53,13 @@ "Se ha producido un error al cargar la configuración de las notificaciones." "No se ha podido silenciar esta sala, inténtalo de nuevo." "Error al dejar de silenciar esta sala, por favor inténtalo de nuevo." + "No cierres la aplicación hasta que se haya finalizado." + "Preparando invitaciones…" "Invitar personas" "Invitar" "Salir de la conversación" "Salir de la sala" - "Medios y archivos" + "Multimedia y archivos" "Personalizado" "Por defecto" "Notificaciones" @@ -58,16 +67,23 @@ "Perfil" "Solicitudes de unión" "Roles y permisos" + "Nombre" "Seguridad y privacidad" "Seguridad" "Compartir sala" "Información de la sala" "Tema" "Actualizando la sala…" - "No hay usuarios baneados." + "No hay usuarios vetados." + + "%1$d vetado" + "%1$d vetados" + + "Revisa la ortografía o intenta una nueva búsqueda." + "Ningún resultado para «%1$s»" - "%1$d Persona" - "%1$d Personas" + "%1$d persona" + "%1$d personas" "Sacar y vetar a un miembro" "Solo eliminar miembro" @@ -76,8 +92,14 @@ "Eliminar veto en la sala" "Vetados" "Miembros" + + "%1$d invitado" + "%1$d invitados" + + "Pendiente" "Administrador" "Moderador" + "Propietario" "Miembros de la sala" "Levantando veto a %1$s" "Permitir configuración personalizada" @@ -92,15 +114,17 @@ "No se pudo cambiar el modo, por favor inténtalo de nuevo." "Tu servidor base no admite esta opción en salas cifradas, no recibirás notificaciones de esta sala." "Todos los mensajes" - "Únicamente Menciones y Palabras clave" "En esta sala, notificarme por" "Administradores" + "Administradores y propietarios" "Cambiar mi rol" "Degradar a miembro" "Degradar a moderador" "Moderación de miembros" "Mensajes y contenido" "Moderadores" + "Propietarios" + "Permisos" "Restablecer permisos" "Una vez que restablezca los permisos, perderá la configuración actual." "¿Restablecer los permisos?" @@ -110,8 +134,10 @@ "Marcar como leído" "Marcar como no leído" "Agregar dirección" + "Cualquiera en espacios autorizados puede unirse, pero el resto debe solicitar acceso." "Todos deben solicitar acceso." "Solicitar unirse" + "Cualquiera en %1$s puede unirse, pero el resto debe solicitar acceso." "Sí, activar cifrado" "Una vez activado, el cifrado de una sala no se puede desactivar. El historial de mensajes solo será visible para los miembros de la sala desde que fueron invitados o desde que se unieron a la sala. Nadie más que los miembros de la sala podrán leer los mensajes. Esto puede impedir que los bots y los puentes funcionen correctamente. @@ -122,21 +148,29 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr "Activar el cifrado de extremo a extremo" "Cualquiera puede unirse" "Cualquiera" + "Elige qué miembros de los espacios pueden unirse a esta sala sin una invitación. %1$s" + "Gestionar espacios" "Solo las personas invitadas pueden unirse." "Solo por invitación" "Acceso" + "Cualquiera en espacios autorizados puede unirse." + "Cualquiera en %1$s puede unirse." + "Miembros del espacio" "No se admiten los espacios por el momento." "Necesitarás una dirección de sala para que sea visible en el directorio." "Dirección" "Permite encontrar esta sala buscando en el directorio de salas públicas de %1$s" + "Permitir que sea encontrada al buscar en el directorio público." "Visible en el directorio público" "Cualquiera (el historial es público)" + "Los cambios no afectarán a los mensajes anteriores, solo a los nuevos. %1$s" "Quién puede leer el historial" - "Solo participantes desde que fueron invitados" - "Miembros (historia completa)" + "Miembros desde su invitación" + "Miembros (historial completo)" "Las direcciones de sala son formas de buscar salas y acceder a ellas. Esto también garantiza que puedas compartir fácilmente tu sala con otras personas. Puedes optar por publicar tu sala en el directorio de salas públicas de tu servidor base." "Publicación de la sala" + "Las direcciones son una forma de encontrar y acceder a salas y espacios. También garantizan que puedas compartirlos fácilmente con otras personas." "Visibilidad" "Seguridad y privacidad" diff --git a/features/roomdetails/impl/src/main/res/values-et/translations.xml b/features/roomdetails/impl/src/main/res/values-et/translations.xml index e6962456ac..329ac267bf 100644 --- a/features/roomdetails/impl/src/main/res/values-et/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-et/translations.xml @@ -113,8 +113,7 @@ "Vaikimisi seadistuste taastamine ei õnnestunud. Palun proovi uuesti." "Seadistuste muutmine ei õnnestunud. Palun proovi uuesti." "Sinu koduserver ei toeta seda võimalust krüptitud jututubades, seega sa ei saa selle jututoa kohta teavitusi." - "Kõikide sõnumite korral" - "Mainimiste ja võtmesõnade alusel" + "Kõik sõnumid" "Selles jututoas teavita mind" "Peakasutajad" "Peakasutajad ja omanikud" diff --git a/features/roomdetails/impl/src/main/res/values-eu/translations.xml b/features/roomdetails/impl/src/main/res/values-eu/translations.xml index 64f32e44d9..e00aff7d4c 100644 --- a/features/roomdetails/impl/src/main/res/values-eu/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-eu/translations.xml @@ -83,7 +83,6 @@ "Errorea gertatu da jakinarazpen-ezarpenak kargatzean." "Zure zerbitzaria ez da bateragarria enkriptatutako gelen aukerarekin; litekeena da gela batzuetako jakinarazpenak ez jasotzea." "Mezu guztiak" - "Aipamenak eta hitz gakoak soilik" "Gela honetan, jakinarazi" "Administratzaileak" "Administratzaileak eta jabeak" diff --git a/features/roomdetails/impl/src/main/res/values-fa/translations.xml b/features/roomdetails/impl/src/main/res/values-fa/translations.xml index 7cdfe409dd..1ea8bc2e69 100644 --- a/features/roomdetails/impl/src/main/res/values-fa/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fa/translations.xml @@ -1,6 +1,10 @@ - "نشانی اتاق" + "اعضای جدید تاریخچه را نمی‌بینند" + "اعضای جدید تاریخچه را می‌بینند" + "هر کسی می‌تواند تاریخچه را ببیند" + "برای اینکه بتوانید آن را در فهرست عمومی مشاهده کنید، به یک آدرس نیاز دارید." + "ویرایش آدرس" "هنگام به‌روز کردن تنظیمات آگاهی خطایی رخ داد." "کارساز خانگیتان از این گزینه در اتاق‌های رمز شده پشتیبانی نمی‌کند. ممکن است در برخی اتاق‌ها آگاه نشوید." "نظرسنجی‌ها" @@ -21,6 +25,7 @@ "ویرایش مدیران" "قادر نخواهید بود این کنش را بازکردانید. داردید کاربر را به سطح قدرت خودتان ارتقا می‌دهید." "افزودن مدیر؟" + "شما قادر به لغو این اقدام نخواهید بود. شما در حال انتقال مالکیت به کاربران انتخاب شده هستید. پس از خروج، این انتقال دائمی خواهد بود." "انتقال مالکیت؟" "تنزل بده" "شما نمی‌توانید این تغییر را بازگردانید زیرا در حال تنزل نقش خود در اتاق هستید، اگر آخرین کاربر ممتاز در اتاق باشید، امکان دستیابی مجدد به دسترسی‌های سطح بالای اتاق غیرممکن است." @@ -62,6 +67,7 @@ "نمایه" "درخواست‌های پیوستن" "نقش‌ها و مجوزها" + "نام" "امنیت و محرمانگی" "امنیت" "هم‌رسانی اتاق" @@ -69,6 +75,12 @@ "موضوع" "به‌روز کردن اتاق…" "هیچ کاربری محروم نشده." + + "%1$d محروم شد" + "%1$d محروم شد" + + "املای کلمات را بررسی کنید یا جستجوی جدیدی را امتحان کنید" + "هیچ نتیجه‌ای برای «%1$s» یافت نشد." "%1$d نفر" "%1$d نفر" @@ -80,6 +92,11 @@ "تحریم نکردن از اتاق" "محروم" "اعضا" + + "%1$d دعوت شده" + "%1$d دعوت شده" + + "در حال بررسی" "ادمین" "ناظم" "مالک" @@ -108,6 +125,7 @@ "پیام‌ها و محتوا" "ناظم‌ها" "مالکان" + "دسترسی‌ها" "بازنشانی اجازه‌ها" "پس از بازنشانی اجازه‌ها تنظیمات فعلی را از دست خواهید داد." "بازنشانی اجازه‌ها؟" @@ -116,21 +134,44 @@ "نقش‌ها و مجوزها" "علامت‌گذاری به عنوان خوانده شده" "نشان به ناخوانده" - "افزودن نشانی اتاق" + "اضافه کردن آدرس" + "هر کسی که در فضاهای مجاز باشد می‌تواند عضو شود، اما بقیه باید درخواست دسترسی بدهند." + "همه باید درخواست دسترسی بدهند." "درخواست دعوت" + "هر کسی در %1$s می‌تواند عضو شود، اما بقیه باید درخواست دسترسی بدهند." "بله. به کار انداختن رمزنگاری" + "پس از فعال کردن، رمزگذاری برای یک اتاق قابل غیرفعال کردن نیست، تاریخچه پیام فقط برای اعضای اتاق از زمان دعوت یا از زمان پیوستن به اتاق قابل مشاهده خواهد بود. +هیچ کس غیر از اعضای اتاق قادر به خواندن پیام‌ها نخواهد بود. این ممکن است مانع از عملکرد صحیح ربات‌ها و پل‌ها شود. +ما فعال کردن رمزگذاری را برای اتاق‌هایی که هر کسی می‌تواند آنها را پیدا کند و به آنها بپیوندد، توصیه نمی‌کنیم." "رمزگذاری فعال شود؟" "پس از به کار افتادن، رمزنگاری قابل از کار انداختن نیست." "رمزنگاری" - "هرکسی می‌تواند یافته و بپیوندد" - "هرکسی" - "افراد فقط در صورت دعوت می‌توانند بپیوندند" - "فقط دعوتی" - "دسترسی اتاق" + "فعال کردن رمزنگاری سراسری" + "هر کسی می‌تواند عضو شود." + "هر کس" + "انتخاب کنید که اعضای کدام فضاها می‌توانند بدون دعوتنامه به این اتاق بپیوندند. %1$s" + "مدیریت فضاها" + "فقط افراد دعوت شده می‌توانند عضو شوند." + "فقط دعوت" + "دسترسی" + "هر کسی که در فضاهای مجاز باشد می‌تواند عضو شود." + "هر کسی که در %1$s باشد می‌تواند عضو شود." + "اعضای فضا" "در حال حاضر فضاها پشتیبانی نمی‌شوند" - "نشانی اتاق" + "برای اینکه بتوانید آن را در فهرست عمومی مشاهده کنید، به یک آدرس نیاز دارید." + "آدرس" + "با جستجو در فهرست اتاق‌های عمومی %1$s، امکان یافتن این اتاق را فراهم کنید." + "با جستجو در فهرست عمومی، می‌توان آن را پیدا کرد." + "قابل مشاهده در فهرست عمومی" "هرکسی" + "تغییرات روی پیام‌های قبلی تأثیری نخواهند داشت، فقط روی پیام‌های جدید اعمال می‌شوند. %1$s" + "چه کسی می‌تواند تاریخ بخواند؟" + "اعضا از زمان دعوت" + "اعضا (تاریخچه کامل)" + "آدرس‌های اتاق، راه‌هایی برای پیدا کردن و دسترسی به اتاق‌ها هستند. این همچنین تضمین می‌کند که می‌توانید به راحتی اتاق خود را با دیگران به اشتراک بگذارید. +شما می‌توانید اتاق خود را در دایرکتوری عمومی اتاق سرور خانگی خود منتشر کنید." "انتشار اتاق" - "نمایانی اتاق" + "آدرس‌ها راهی برای پیدا کردن و دسترسی به اتاق‌ها و فضاها هستند. این همچنین تضمین می‌کند که می‌توانید به راحتی آنها را با دیگران به اشتراک بگذارید." + "قابلیت مشاهده" "امنیت و محرمانگی" diff --git a/features/roomdetails/impl/src/main/res/values-fi/translations.xml b/features/roomdetails/impl/src/main/res/values-fi/translations.xml index 4eafca087e..b6586d8884 100644 --- a/features/roomdetails/impl/src/main/res/values-fi/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fi/translations.xml @@ -115,6 +115,7 @@ "Kotipalvelimesi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et saa ilmoituksia tästä huoneesta." "Kaikista viesteistä" "Vain maininnoista ja avainsanoista" + "Mykistä" "Ilmoita minulle tässä huoneessa" "Ylläpitäjät" "Ylläpitäjät ja omistajat" diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml index 0d6844cb7e..237767c82a 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -114,8 +114,9 @@ "Échec de la configuration du mode, veuillez réessayer." "Votre serveur d’accueil ne supporte pas cette option pour les salons chiffrés, vous ne serez pas notifié(e) dans ce salon." "Tous les messages" - "Mentions et mots clés uniquement" - "Dans ce salon, prévenez-moi pour" + "Les messages avec mentions et mots clés uniquement" + "Aucun message" + "Pour ce salon, afficher une notification pour" "Administrateurs" "Administrateurs et propriétaires" "Changer mon rôle" diff --git a/features/roomdetails/impl/src/main/res/values-hr/translations.xml b/features/roomdetails/impl/src/main/res/values-hr/translations.xml index 5619164dd2..fffdc33e40 100644 --- a/features/roomdetails/impl/src/main/res/values-hr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-hr/translations.xml @@ -116,8 +116,6 @@ "Vraćanje zadanog načina rada nije uspjelo, pokušajte ponovno." "Postavljanje načina rada nije uspjelo, pokušajte ponovno." "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; nećete dobiti obavijest u ovoj sobi." - "Sve poruke" - "Samo spominjanja i ključne riječi" "U ovoj sobi obavijesti me za" "Administratori" "Administratori i vlasnici" diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml index b8a18d4421..c876567303 100644 --- a/features/roomdetails/impl/src/main/res/values-it/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -114,7 +114,6 @@ "Impossibile impostare la modalità, riprova." "Il tuo homeserver non supporta questa opzione nelle stanze cifrate, quindi non riceverai notifiche in questa stanza." "Tutti i messaggi" - "Solo menzioni e parole chiave" "In questa stanza, avvisami per" "Amministratori" "Amministratori e proprietari" diff --git a/features/roomdetails/impl/src/main/res/values-ja/translations.xml b/features/roomdetails/impl/src/main/res/values-ja/translations.xml index bf0ef7424c..830ebd5452 100644 --- a/features/roomdetails/impl/src/main/res/values-ja/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ja/translations.xml @@ -110,8 +110,6 @@ "デフォルトの復元に失敗しました。再試行してください。" "設定に失敗しました。再試行してください。" "暗号化されたルームでは、この機能にホームサーバーが対応しないため、このルームからの通知を受信できません。" - "すべてのメッセージ" - "メンションとキーワードのみ" "このルームでは以下の通知を受け取る" "管理者" "管理者と所有者" diff --git a/features/roomdetails/impl/src/main/res/values-ka/translations.xml b/features/roomdetails/impl/src/main/res/values-ka/translations.xml index 8fd0b2c8a6..6bc01b34b9 100644 --- a/features/roomdetails/impl/src/main/res/values-ka/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ka/translations.xml @@ -79,7 +79,6 @@ "რეჟიმის დაყენება ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." "თქვენი სახლის სერვერი არ უჭერს მხარს ამ პარამეტრს დაშიფრულ ოთახებში, თქვენ არ მიიღებთ შეტყობინებას ამ ოთახში." "ყველა შეტყობინება" - "მხოლოდ ხსენებები და საკვანძო სიტყვები" "ამ ოთახში, შემატყობინეთ:" "ადმინისტრატორები" "ჩემი როლის შეცვლა" diff --git a/features/roomdetails/impl/src/main/res/values-ko/translations.xml b/features/roomdetails/impl/src/main/res/values-ko/translations.xml index 4a32cab45d..3d0a106e25 100644 --- a/features/roomdetails/impl/src/main/res/values-ko/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ko/translations.xml @@ -111,7 +111,6 @@ "모드 설정이 실패했습니다. 다시 시도해 주세요." "귀하의 홈 서버는 암호화된 방에서 이 옵션을 지원하지 않으므로, 이 방에서 알림을 받지 못합니다." "모든 메시지" - "언급 및 키워드만" "이 방에서, 알림을 주세요" "관리자" "관리자 및 소유자" diff --git a/features/roomdetails/impl/src/main/res/values-nb/translations.xml b/features/roomdetails/impl/src/main/res/values-nb/translations.xml index 0cb3dfc268..8507587f95 100644 --- a/features/roomdetails/impl/src/main/res/values-nb/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-nb/translations.xml @@ -114,7 +114,6 @@ "Innstilling av modus mislyktes, prøv igjen." "Hjemmeserveren din støtter ikke dette alternativet i krypterte rom, og du vil ikke bli varslet i dette rommet." "Alle meldinger" - "Bare omtaler og nøkkelord" "I dette rommet, varsle meg om" "Administratorer" "Administratorer og eiere" diff --git a/features/roomdetails/impl/src/main/res/values-nl/translations.xml b/features/roomdetails/impl/src/main/res/values-nl/translations.xml index 2ee5caca97..a25a674edc 100644 --- a/features/roomdetails/impl/src/main/res/values-nl/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-nl/translations.xml @@ -85,7 +85,6 @@ "Het instellen van de meldingen is mislukt. Probeer het opnieuw." "Je homeserver ondersteunt deze optie niet in versleutelde kamers; in deze kamer krijg je geen meldingen." "Alle berichten" - "Alleen vermeldingen en trefwoorden" "In deze kamer, stuur me een melding voor" "Beheerders" "Mijn rol wijzigen" diff --git a/features/roomdetails/impl/src/main/res/values-pl/translations.xml b/features/roomdetails/impl/src/main/res/values-pl/translations.xml index b79d3014e5..14225bcdf9 100644 --- a/features/roomdetails/impl/src/main/res/values-pl/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml @@ -118,6 +118,7 @@ "Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z tego pokoju." "Wszystkie wiadomości" "Tylko wzmianki i słowa kluczowe" + "Wycisz" "W tym pokoju, powiadamiaj mnie przez" "Administratorzy" "Administratorzy i właściciele" diff --git a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml index f2ddfb86cb..e741c43c90 100644 --- a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml @@ -111,7 +111,6 @@ "Falha ao definir o modo, tente novamente." "Seu servidor-casa não suporta esta opção em salas criptografadas, você não será notificado nesta sala." "Todas as mensagens" - "Somente menções e palavras-chave" "Nesta sala, notifique-me de" "Administradores" "Administradores e proprietários" diff --git a/features/roomdetails/impl/src/main/res/values-pt/translations.xml b/features/roomdetails/impl/src/main/res/values-pt/translations.xml index 04ded84480..53eaebb934 100644 --- a/features/roomdetails/impl/src/main/res/values-pt/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pt/translations.xml @@ -109,8 +109,7 @@ "Falha ao restaurar o modo predefinido, tenta novamente." "Falha ao definir o modo, tenta novamente." "O teu servidor não suporta esta opção em salas cifradas, pelo que não serás notificado nesta sala." - "Qualquer mensagem" - "Menções ou palavras-chave" + "Todas as mensagens" "Nesta sala, notifica-me se" "Administradores" "Administradores e donos" diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml index f3feda811a..ced134da3d 100644 --- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -118,6 +118,7 @@ "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, nu veți primi notificări în această cameră." "Toate mesajele" "Numai mențiuni și cuvinte cheie" + "Mut" "În această cameră, anunțați-mă pentru" "Administratori" "Administratori și proprietari" diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml index aa9f244498..764b18de76 100644 --- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml @@ -99,7 +99,6 @@ "Misslyckades att ställa in läget, vänligen pröva igen." "Din hemserver stöder inte det här alternativet i krypterade rum, du blir inte aviserad i det här rummet." "Alla meddelanden" - "Endast omnämnanden och nyckelord" "I det här rummet, meddela mig för" "Administratörer" "Administratörer och ägare" diff --git a/features/roomdetails/impl/src/main/res/values-tr/translations.xml b/features/roomdetails/impl/src/main/res/values-tr/translations.xml index a9bfc6fbe2..9f78649509 100644 --- a/features/roomdetails/impl/src/main/res/values-tr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-tr/translations.xml @@ -97,7 +97,6 @@ "Ayarlanamadı, lütfen tekrar deneyin." "Ana sunucunuz şifreli odalarda bu seçeneği desteklemiyor, bu odada bildirim almayacaksınız." "Tüm mesajlar" - "Yalnızca Bahsetmeler ve Anahtar Kelimeler" "Bu odada, bana bildir" "Yöneticiler" "Rolümü değiştir" diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml index 8f566bb235..edc1fab508 100644 --- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -117,7 +117,8 @@ "Не вдалося встановити режим, спробуйте ще раз." "Ваш домашній сервер не підтримує цю опцію в зашифрованих кімнатах, ви не отримаєте сповіщення в цій кімнаті." "Всі повідомлення" - "Тільки згадки та ключові слова" + "Лише згадки та ключові слова" + "Вимкнути звук" "У цій кімнаті сповіщати мене про" "Адміністратори" "Адміністратори та власники" diff --git a/features/roomdetails/impl/src/main/res/values-ur/translations.xml b/features/roomdetails/impl/src/main/res/values-ur/translations.xml index 5c32811f36..89387da0de 100644 --- a/features/roomdetails/impl/src/main/res/values-ur/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ur/translations.xml @@ -83,8 +83,6 @@ "متعین وضع کو بحال کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" "وضع ترتیب دینے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" "آپ کا منزلی خادم مرموز کردہ کمروں میں اس اختیار کی حمایت نہیں کرتا ہے، آپ کو اس کمرے میں مطلع کیا جائے گا۔" - "تمام پیغامات" - "صرف تذکرے اور کلیدی الفاظ" "اس کمرے میں، مجھے مطلع کریں برائے" "منتظمین" "میرا کردار تبدیل کریں" diff --git a/features/roomdetails/impl/src/main/res/values-uz/translations.xml b/features/roomdetails/impl/src/main/res/values-uz/translations.xml index d7778f2df4..10ffe9cf95 100644 --- a/features/roomdetails/impl/src/main/res/values-uz/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uz/translations.xml @@ -114,7 +114,6 @@ "Rejimni o‘rnatib bo‘lmadi, qayta urinib ko‘ring." "Uy serveringiz shifrlangan xonalarda ushbu imkoniyatni qoʻllab-quvvatlamaydi, shuning uchun bu xonadan bildirishnomalar olmaysiz." "Barcha xabarlar" - "Faqat eslatmalar va kalit so\'zlar" "Bu xonada menga xabar bering" "Adminlar" "Adminlar va egalari" diff --git a/features/roomdetails/impl/src/main/res/values-vi/translations.xml b/features/roomdetails/impl/src/main/res/values-vi/translations.xml index ecc7e4e227..df42b37f77 100644 --- a/features/roomdetails/impl/src/main/res/values-vi/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-vi/translations.xml @@ -90,8 +90,6 @@ "Không thể khôi phục chế độ mặc định, vui lòng thử lại." "Không thể thiết lập chế độ, hãy thử lại nhé." "Máy chủ không hỗ trợ tùy chọn này trong phòng mã hóa, bạn sẽ không nhận thông báo ở đây." - "Tất cả tin nhắn." - "Chỉ đề cập và từ khóa" "Trong phòng này, thông báo cho tôi khi" "Quản trị viên" "Thay đổi vai trò của tôi" diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml index 8a5c5734ed..a7592c38c7 100644 --- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -111,7 +111,8 @@ "设置模式失败,请重试。" "主服务器不支持在加密房间中的此选项,因此在此房间你可能无法收到通知。" "所有消息" - "仅限提及和关键词" + "仅提及与关键词" + "静默" "在此房间通知我以下类型" "管理员" "管理员与所有者" diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 31851c2603..05bdb50d6e 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -115,6 +115,7 @@ "Your homeserver does not support this option in encrypted rooms, you won\'t get notified in this room." "All messages" "Mentions and replies only" + "Mute" "In this room, notify me for" "Admins" "Admins and owners" diff --git a/features/roomdetails/impl/src/main/res/values/temporary.xml b/features/roomdetails/impl/src/main/res/values/temporary.xml deleted file mode 100644 index b3194f3323..0000000000 --- a/features/roomdetails/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - Mute - diff --git a/features/roomdetailsedit/impl/src/main/res/values-ar/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..5d58de994f --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,7 @@ + + + "تعديل التفاصيل" + "قد كان هناك خطأ والمعلومات لم تتحدث." + "غير قادر على تعديل الغرفة" + "يتم تحديث الغرفة…" + diff --git a/features/roomdirectory/impl/src/main/res/values-ar/translations.xml b/features/roomdirectory/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..ff40f27f27 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,5 @@ + + + "فشل التحميل" + "دليل الغرف" + diff --git a/features/roommembermoderation/impl/src/main/res/values-ar/translations.xml b/features/roommembermoderation/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..3a425d4d7d --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,22 @@ + + + "حظر المستخدم" + "حظر" + "لن يتمكن هذا العضو من الانضمام مرة أخرى حتى إذا تمت دعوته." + "هل أنت متأكد من أنك تريد حظر هذا العضو؟" + "لن يتمكن هذا المستخدم من الانضمام إلى هذه المساحة مرة أخرى إذا تمت دعوته، لكنه سيظل محتفظًا بعضويته في أي غرف أو مساحات فرعية." + "جارٍ حظر %1$s" + "إزالة" + "يمكن لهذا المستخدم الانضمام إلى هذه الغرفة مرة أخرى عند دعوته." + "هل أنت متأكد من رغبتك في إزالة هذا العضو؟" + "سيتمكن هذا المستخدم من الانضمام إلى هذه المساحة مرة أخرى إذا تمت دعوته، وسيظل محتفظًا بعضويته في أي غرف أو مساحات فرعية." + "عرض الملف الشخصي" + "إزالة المستخدم" + "إزالة العضو وحظره من الانضمام مستقبلًا؟" + "جارٍ إزالة %1$s…" + "إلغاء حظر المستخدم" + "إلغاء الحظر" + "سيتمكن هذا المستخدم من الانضمام مرة أخرى إذا تمت دعوته" + "هل أنت متأكد من رغبتك في إلغاء حظر هذا العضو؟" + "جارٍ رفع حظر %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-es/translations.xml b/features/roommembermoderation/impl/src/main/res/values-es/translations.xml index 7f83f7546e..7f4a1e96cf 100644 --- a/features/roommembermoderation/impl/src/main/res/values-es/translations.xml +++ b/features/roommembermoderation/impl/src/main/res/values-es/translations.xml @@ -4,10 +4,12 @@ "Vetar" "No podrán volver a unirse a esta sala si son invitados." "¿Estás seguro de que quieres vetar a este miembro?" + "No podrá volver a unirse a este espacio aunque le inviten, pero seguirá siendo miembro de cualquier sala o subespacio donde ya lo fuese." "Vetando a %1$s" "Echar" "Podrá volver a unirse a esta sala si se le invita." "¿Seguro que quieres echar a este miembro?" + "Podrá volver a unirse a este espacio si se le invita, y seguirá siendo miembro de cualquier sala o subespacio donde ya lo fuese." "Ver perfil" "Sacar de la sala" "¿Sacar al miembro y prohibirle unirse en el futuro?" diff --git a/features/roommembermoderation/impl/src/main/res/values-fa/translations.xml b/features/roommembermoderation/impl/src/main/res/values-fa/translations.xml index 6ddb482189..d647dd1915 100644 --- a/features/roommembermoderation/impl/src/main/res/values-fa/translations.xml +++ b/features/roommembermoderation/impl/src/main/res/values-fa/translations.xml @@ -4,15 +4,19 @@ "تحریم" "در صورت دعوت نمی‌تواند دوباره به اتاق بپیوندد." "مطمئنید می‌خواهید این عضو را تحریم کنید؟" + "در صورت دعوت، آنها دیگر نمی‌توانند به این فضا بپیوندند، اما همچنان عضویت خود در هر اتاق یا زیرفضا را حفظ خواهند کرد." "تحریم کردن %1$s" "برداشتن" "در صورت دعوت می‌تواند دوباره به اتاق بپیوندد." "مطمئنید می‌خواهید این عضو را بردارید؟" + "در صورت دعوت، آنها می‌توانند دوباره به این فضا بپیوندند و همچنان عضویت خود را در هر اتاق یا زیرفضا حفظ خواهند کرد." "دیدن نمایه" "حذف کاربر" "برداشتن عضو و تحریم پیوستن در آینده؟" "برداشتن %1$s…" "تحریم نکردن از اتاق" "رفع انسداد" + "در صورت دعوت، آنها می‌توانند دوباره بپیوندند" + "آیا مطمئن هستید که می‌خواهید این عضو را از حالت مسدود خارج کنید؟" "رفع تحریم %1$s" diff --git a/features/securebackup/impl/src/main/res/values-ar/translations.xml b/features/securebackup/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..7b3295f49a --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,95 @@ + + + "حذف تخزين المفاتيح" + "تفعيل النسخ الاحتياطي" + "سيتيح لك هذا الاطّلاع على سجل محادثاتك على أي أجهزة جديدة، وهو مطلوب للنسخ الاحتياطي للمحادثات والهوية الرقمية. %1$s." + "تخزين المفاتيح" + "يجب تشغيل تخزين المفاتيح لنسخ دردشاتك احتياطيًا." + "رفع المفاتيح من هذا الجهاز" + "السماح بتخزين المفاتيح" + "تغيير مفتاح الاسترداد" + "يتم نسخ دردشاتك احتياطيًا تلقائيًا بتشفير من طرف إلى طرف. لاستعادة هذه النسخة الاحتياطية والاحتفاظ بهويتك الرقمية إذا فقدت الوصول إلى جميع أجهزتك، ستحتاج إلى مفتاح الاسترداد." + "أدخل مفتاح الاسترداد" + "تخزين المفاتيح لديك غير متزامن حاليًا." + "الحصول على مفتاح الاسترداد" + "يتم نسخ محادثاتك احتياطيًا تلقائيًا باستخدام التشفير التام بين الطرفين. لاستعادة هذه النسخة الاحتياطية والاحتفاظ بهويتك الرقمية عند فقدان الوصول إلى جميع أجهزتك، ستحتاج إلى مفتاح الاسترداد." + "افتح %1$s على جهاز سطح مكتب" + "سجّل الدخول إلى حسابك مرة أخرى" + "عندما يُطلب منك التحقق من جهازك، اختر %1$s" + "“إعادة تعيين الكل”" + "اتّبع التعليمات لإنشاء مفتاح استرداد جديد" + "احفظ مفتاح الاسترداد الجديد في مدير كلمات مرور أو ملاحظة مشفّرة" + "إعادة تعيين تشفير حسابك باستخدام جهاز آخر" + "مفتاح الاسترداد الذي أدخلته غير مطابق" + "إنهاء الإعداد" + "أدخل مفتاح الاسترداد مرة أخرى." + "أكّد مفتاح الاسترداد" + + "الحد الأدنى %1$d أحرف. لا تستخدم كلمة مرور حسابك" + "الحد الأدنى %1$d حرف. لا تستخدم كلمة مرور حسابك" + "الحد الأدنى %1$d حرفان. لا تستخدم كلمة مرور حسابك" + "الحد الأدنى %1$d أحرف. لا تستخدم كلمة مرور حسابك" + "الحد الأدنى %1$d حرفًا. لا تستخدم كلمة مرور حسابك" + "الحد الأدنى %1$d حرف. لا تستخدم كلمة مرور حسابك" + + "القوة" + "قوة عبارة المرور: %1$s" + "متوسط" + "قوي" + "قوي جدًا" + "ضعيف جدًا" + "ضعيف" + "اختر مفتاح استرداد يمكنك تذكّره." + "أدخل مفتاح استرداد" + "جارٍ تحميل متطلبات مفتاح الاسترداد" + "لتغيير مفتاح الاسترداد، انتقل إلى الإعدادات > التشفير > النسخ الاحتياطي" + "يمكنك استخدام مفتاح الاسترداد لتأكيد الأجهزة الجديدة أو استعادة محادثاتك المشفّرة إذا فقدت الوصول إلى جميع أجهزتك." + "تم إعداد نسختك الاحتياطية بالكامل الآن" + "متابعة إعادة التعيين" + "سيتم الاحتفاظ بتفاصيل حسابك وجهات اتصالك وتفضيلاتك وقائمة الدردشات" + "ستفقد أي سجل رسائل مخزّن على الخادم فقط" + "ستحتاج إلى التحقق من جميع أجهزتك وجهات اتصالك الحالية مرة أخرى" + "لا تُعد تعيين هويتك الرقمية إلا إذا لم يكن لديك وصول إلى جهاز آخر تم التحقق منه ولا تملك مفتاح الاسترداد." + "لا يمكنك التأكيد؟ ستحتاج إلى إعادة تعيين هويتك الرقمية." + "حذف" + "ستفقد سجل محادثاتك المشفّر وستحتاج إلى إعادة ضبط هويتك الرقمية إذا أزلت جميع أجهزتك." + "هل أنت متأكد من رغبتك في حذف تخزين المفاتيح؟" + "سيؤدي حذف تخزين المفاتيح إلى إزالة هويتك الرقمية ومفاتيح الرسائل من الخادم وإيقاف ميزات الأمان التالية:" + "لن يتوفر لك سجل الرسائل المشفّرة على الأجهزة الجديدة" + "ستفقد الوصول إلى رسائلك المشفّرة إذا تم تسجيل خروجك من %1$s في كل مكان" + "هل أنت متأكد من رغبتك في إيقاف تخزين المفاتيح وحذفه؟" + "احصل على مفتاح استرداد جديد إذا فقدت مفتاحك الحالي. بعد تغيير مفتاح الاسترداد، لن يعمل مفتاحك القديم بعد ذلك." + "إنشاء مفتاح استرداد جديد" + "لا تشارك هذا مع أي شخص!" + "تم تغيير مفتاح الاسترداد" + "تغيير مفتاح الاسترداد؟" + "إنشاء مفتاح استرداد جديد" + "تأكد من ألا يتمكن أحد من رؤية هذه الشاشة!" + "يُرجى المحاولة مرة أخرى لتأكيد الوصول إلى تخزين المفاتيح." + "مفتاح الاسترداد غير صحيح" + "إذا كان لديك مفتاح أمان أو عبارة أمان، فسيعمل ذلك أيضًا." + "أدخل…" + "هل فقدت مفتاح الاسترداد؟" + "تم تأكيد مفتاح الاسترداد" + "أدخل مفتاح الاسترداد" + "تم نسخ مفتاح الاسترداد" + "جارٍ الإنشاء…" + "حفظ مفتاح الاسترداد" + "دوّن مفتاح الاسترداد هذا في مكان آمن، مثل مدير كلمات مرور أو ملاحظة مشفّرة أو خزنة مادية." + "اضغط لنسخ مفتاح الاسترداد" + "احفظ مفتاح الاسترداد الخاص بك في مكان آمن" + "لن تتمكن من الوصول إلى مفتاح الاسترداد الجديد بعد هذه الخطوة." + "هل حفظت مفتاح الاسترداد الخاص بك؟" + "تخزين المفاتيح لديك محمي بمفتاح استرداد. إذا احتجت إلى مفتاح استرداد جديد بعد الإعداد، يمكنك إعادة إنشائه بتحديد «تغيير مفتاح الاسترداد»." + "إنشاء مفتاح الاسترداد الخاص بك" + "لا تشارك هذا مع أي شخص!" + "تم إعداد الاسترداد بنجاح" + "الحصول على مفتاح الاسترداد" + "نعم، أعد التعيين الآن" + "لا يمكن التراجع عن هذه العملية." + "هل أنت متأكد من أنك تريد إعادة تعيين هويتك الرقمية؟" + "حدث خطأ غير معروف. يُرجى التحقق من صحة كلمة مرور حسابك ثم المحاولة مرة أخرى." + "أدخل…" + "أكّد أنك تريد إعادة تعيين هويتك الرقمية." + "أدخل كلمة مرور حسابك للمتابعة" + diff --git a/features/securebackup/impl/src/main/res/values-es/translations.xml b/features/securebackup/impl/src/main/res/values-es/translations.xml index 0904559a8f..a99c4bf496 100644 --- a/features/securebackup/impl/src/main/res/values-es/translations.xml +++ b/features/securebackup/impl/src/main/res/values-es/translations.xml @@ -2,16 +2,17 @@ "Borrar almacén de claves" "Activar copia de seguridad" - "Almacena tu identidad criptográfica y las claves de tus mensajes de forma segura en el servidor. Esto te permitirá ver tu historial de mensajes en cualquier dispositivo nuevo. %1$s." + "Esto te permitirá ver tu historial de chat en cualquier dispositivo nuevo y es necesario para la copia de seguridad de tus chats y de tu identidad digital. %1$s." "Almacenamiento de claves" - "El almacenamiento de claves debe estar activado para configurar la recuperación." + "El almacenamiento de claves debe estar activado para hacer copias de seguridad de tus chats." "Subir claves desde este dispositivo" "Permitir almacenamiento de claves" "Cambiar la clave de recuperación" - "Recupera tu identidad criptográfica y tu historial de mensajes con una clave de recuperación si has perdido todos tus dispositivos actuales." + "Tus chats se respaldan automáticamente con cifrado de extremo a extremo. Para restaurar esta copia de seguridad y conservar tu identidad digital si pierdes el acceso a todos tus dispositivos, necesitarás tu clave de recuperación." "Introduce la clave de recuperación" "Tu almacén de claves no está sincronizado actualmente." - "Configurar la recuperación" + "Obtener clave de recuperación" + "Tus chats se respaldan automáticamente con cifrado de extremo a extremo. Para restaurar esta copia de seguridad y conservar tu identidad digital si pierdes el acceso a todos tus dispositivos, necesitarás tu clave de recuperación." "Abre %1$s en un dispositivo de escritorio" "Vuelve a iniciar sesión en tu cuenta" "Cuando se te pida que verifiques tu dispositivo, selecciona %1$s" @@ -19,16 +20,37 @@ "Sigue las instrucciones para crear una nueva clave de recuperación" "Guarda tu nueva clave de recuperación en un administrador de contraseñas o en una nota cifrada" "Restablece el cifrado de tu cuenta usando otro dispositivo" + "La clave de recuperación que introdujiste no coincide" + "Finalizar la configuración" + "Introduce de nuevo tu clave de recuperación." + "Confirma tu clave de recuperación" + + "Mínimo %1$d carácter. No uses la contraseña de tu cuenta" + "Mínimo %1$d caracteres. No uses la contraseña de tu cuenta" + + "Fortaleza" + "Fortaleza de la frase de contraseña: %1$s" + "Moderada" + "Fuerte" + "Muy fuerte" + "Muy débil" + "Débil" + "Elige una clave de recuperación que puedas memorizar." + "Introduce una clave de recuperación" + "Cargando requisitos de la clave de recuperación" + "Para cambiar tu clave de recuperación, ve a Ajustes → Cifrado → Copia de seguridad" + "Puedes usar tu clave de recuperación para confirmar dispositivos nuevos o restaurar tus chats cifrados si pierdes el acceso a todos tus dispositivos." + "Tu copia de seguridad está ahora totalmente configurada" "Continuar con el restablecimiento" "Se conservarán los detalles de tu cuenta, tus contactos, tus preferencias y tu lista de chats" "Perderás cualquier historial de mensajes que solo esté almacenado en el servidor" "Tendrás que verificar de nuevo todos tus dispositivos y contactos existentes" - "Restablece tu identidad solo si no tienes acceso a otro dispositivo en el que hayas iniciado sesión y has perdido tu clave de recuperación." - "¿No puedes confirmar? Tendrás que restablecer tu identidad." - "Desactivar" - "Perderás tus mensajes cifrados si cierras sesión en todos los dispositivos." - "¿Estás seguro de que quieres desactivar la copia de seguridad?" - "Al borrar el almacén de claves, se eliminarán del servidor tu identidad criptográfica y claves de los mensajes, y se desactivarán las siguientes funciones de seguridad:" + "Restablece tu identidad digital solo si ya no tienes acceso a ningún otro dispositivo verificado y no tienes tu clave de recuperación." + "¿No puedes confirmar? Tendrás que restablecer tu identidad digital." + "Eliminar" + "Perderás tu historial de chats cifrados y tendrás que restablecer tu identidad digital si eliminas todos tus dispositivos." + "¿Estás seguro de que quieres eliminar el almacén de claves?" + "Al borrar el almacén de claves, se eliminarán del servidor tu identidad digital y claves de los mensajes, y se desactivarán las siguientes funciones de seguridad:" "No tendrás un historial de mensajes cifrados en nuevos dispositivos" "Perderás el acceso a tus mensajes cifrados si cierras sesión en %1$s en todas partes" "¿Estás seguro de que quieres desactivar el almacenamiento de claves y borrarlo?" @@ -58,12 +80,12 @@ "Generar tu clave de recuperación" "¡No la compartas con nadie!" "Configuración de recuperación terminada" - "Configurar la recuperación" + "Obtener clave de recuperación" "Sí, restablecer ahora" "Este proceso es irreversible." - "¿Estás seguro de que quieres restablecer tu identidad?" + "¿Estás seguro de que quieres restablecer tu identidad digital?" "Se ha producido un error desconocido. Comprueba que la contraseña de tu cuenta sea correcta y vuelve a intentarlo." "Introducir…" - "Confirma que quieres restablecer tu identidad." + "Confirma que quieres restablecer tu identidad digital." "Introduce la contraseña de tu cuenta para continuar" diff --git a/features/securebackup/impl/src/main/res/values-fa/translations.xml b/features/securebackup/impl/src/main/res/values-fa/translations.xml index bd7a43ffba..9457bcb2af 100644 --- a/features/securebackup/impl/src/main/res/values-fa/translations.xml +++ b/features/securebackup/impl/src/main/res/values-fa/translations.xml @@ -4,12 +4,15 @@ "روشن کردن پشتیبان" "ذخیرهٔ کلیدهای پیام‌ها و هویت رمزنگاریتان به صورت امن روی کارساز. این کار می‌گذارد تاریخچهٔ پیام‌هایتان را روی هر افزارهٔ جدیدی ببینید. %1$s." "ذخیره‌ساز کلید" + "برای پشتیبان‌گیری از چت‌هایتان، باید گزینه‌ی ذخیره‌سازی کلید (Key Storage) فعال باشد." "بارگذاری کلیدها از ین افزاره" "اجازهٔ ذخیرهٔ کلید" "تغییر کلید بازیابی" + "چت‌های شما به طور خودکار با رمزگذاری سراسری پشتیبان‌گیری می‌شوند. برای بازیابی این پشتیبان‌گیری و حفظ هویت دیجیتال خود در زمانی که دسترسی به تمام دستگاه‌هایتان را از دست می‌دهید، به کلید بازیابی خود نیاز خواهید داشت." "ورود کلید بازیابی" "ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده." "دریافت کلید بازیابی" + "چت‌های شما به طور خودکار با رمزگذاری سراسری پشتیبان‌گیری می‌شوند. برای بازیابی این پشتیبان‌گیری و حفظ هویت دیجیتال خود در زمانی که دسترسی به تمام دستگاه‌هایتان را از دست می‌دهید، به کلید بازیابی خود نیاز خواهید داشت." "گشودن %1$s در افزارهٔ میزکار" "ورود دوباره به حسابتان" "گزینش %1$s هنگام درخواست تأیید افزاره‌تان" @@ -17,12 +20,33 @@ "پیروی از دستورالعمل‌ها برای ایجاد کلید بازیابی جدید" "ذخیرهٔ کلید بازیابی جدیدتان در مدیر گذرواژه یا یادداشت رمز شده" "بازنشانی رمزنگاری برای حسابتان با استفاده از افزاره‌ای دیگر" + "کلید بازیابی که وارد کرده‌اید مطابقت ندارد" + "پایان تنظیمات" + "دوباره کلید بازیابی خود را وارد کنید." + "کلید بازیابی خود را تأیید کنید" + + "حداقل کاراکتر %1$d. از رمز عبور حساب کاربری خود استفاده نکنید." + "حداقل کاراکترهای %1$d. از رمز عبور حساب کاربری خود استفاده نکنید." + + "قدرت" + "قدرت رمز عبور: %1$s" + "متوسط" + "قوی" + "خیلی قوی" + "خیلی ضعیف" + "ضعیف" + "یک کلید بازیابی انتخاب کنید که بتوانید آن را به خاطر بسپارید." + "یک کلید بازیابی وارد کنید" + "بارگذاری الزامات کلید بازیابی" + "برای تغییر کلید بازیابی خود، به تنظیمات → رمزگذاری → پشتیبان‌گیری بروید." + "اگر دسترسی به همه دستگاه‌هایتان را از دست بدهید، می‌توانید از کلید بازیابی خود برای تأیید دستگاه‌های جدید یا بازیابی چت‌های رمزگذاری‌شده‌تان استفاده کنید." + "پشتیبان‌گیری شما اکنون کاملاً راه‌اندازی شده است" "ادامهٔ بازنشانی" "جزییات حساب، آشنایان، ترجیحات و سیاههٔ گپ‌هایتان حفظ خواهند شد" "تاریخچهٔ گپ‌هایتان را از دست خواهید داد" "لازم است دوباره همهٔ آشنایان و افزاره‌های موجودتان را تأیید کنید" - "فقط اگر به افزاره‌ای وارد شده از پیش دسترسی ندارید و کلید بازیابیتان را گم کرده‌اید بازنشانی کنید." - "نمی‌توانید تأیید کنید؟ لازم است هویتتان را بازنشانی کنید." + "فقط در صورتی که به دستگاه تأیید شده دیگری دسترسی ندارید و کلید بازیابی خود را ندارید، هویت دیجیتال خود را بازنشانی کنید." + "نمی‌توانید تأیید کنید؟ باید هویت دیجیتال خود را مجدداً تنظیم کنید." "حذف" "اگر از تمام دستگاه‌هایتان خارج شوید، تاریخچه چت رمزگذاری‌شده خود را از دست خواهید داد و باید هویت دیجیتال خود را مجدداً تنظیم کنید." "آیا مطمئن هستید که می‌خواهید کلید ذخیره‌سازی را حذف کنید؟" @@ -59,5 +83,9 @@ "دریافت کلید بازیابی" "بله. اکنون بازنشانی شود" "این فرایند بازگشت‌ناپذیر است." + "آیا مطمئن هستید که می‌خواهید هویت دیجیتال خود را بازنشانی کنید؟" + "خطای ناشناخته‌ای رخ داده است. لطفاً بررسی کنید که رمز عبور حساب شما صحیح است و دوباره امتحان کنید." "ورود…" + "تأیید کنید که می‌خواهید هویت دیجیتال خود را مجدداً تنظیم کنید." + "برای ادامه، رمز عبور حساب خود را وارد کنید" diff --git a/features/securityandprivacy/impl/src/main/res/values-ar/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..d6006ec8f3 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,50 @@ + + + "تحتاج إلى عنوان حتى تظهر الغرفة في الدليل العام." + "تعديل العنوان" + "المساحات التي يمكن لأعضائها الانضمام إلى الغرفة دون دعوة." + "إدارة المساحات" + "(مساحة غير معروفة)" + "مساحات أخرى لست عضوًا فيها" + "مساحاتك" + "إضافة عنوان" + "يمكن لأي شخص في المساحات المصرّح بها الانضمام، لكن يجب على الآخرين طلب الوصول." + "يجب على الجميع طلب الوصول." + "طلب الانضمام" + "يمكن لأي شخص في %1$s الانضمام، لكن يجب على الآخرين طلب الوصول." + "نعم، فعّل التشفير" + "بعد تفعيله، لا يمكن تعطيل التشفير في الغرفة. لن يكون سجل الرسائل مرئيًا لأعضاء الغرفة إلا منذ وقت دعوتهم أو منذ انضمامهم إلى الغرفة. +لن يتمكن أي شخص غير أعضاء الغرفة من قراءة الرسائل. قد يمنع ذلك البوتات والجسور من العمل بشكل صحيح. +لا نوصي بتفعيل التشفير للغرف التي يمكن لأي شخص العثور عليها والانضمام إليها." + "تفعيل التشفير؟" + "بعد تفعيله، لا يمكن تعطيل التشفير." + "التشفير" + "تفعيل التشفير من طرف إلى طرف" + "يمكن لأي شخص الانضمام." + "أي شخص" + "اختر المساحات التي يمكن لأعضائها الانضمام إلى هذه الغرفة دون دعوة. %1$s" + "إدارة المساحات" + "يمكن للأشخاص المدعوين فقط الانضمام." + "بالدعوة فقط" + "الوصول" + "يمكن لأي شخص في المساحات المصرّح بها الانضمام." + "يمكن لأي شخص في %1$s الانضمام." + "أعضاء المساحة" + "المساحات غير مدعومة حاليًا" + "تحتاج إلى عنوان حتى تظهر الغرفة في الدليل العام." + "العنوان" + "السماح بالعثور على هذه الغرفة عند البحث في دليل الغرف العام على %1$s" + "السماح بالعثور عليها عند البحث في الدليل العام." + "مرئية في الدليل العام" + "أي شخص (السجل عام)" + "لن تؤثر التغييرات على الرسائل السابقة، بل على الرسائل الجديدة فقط. %1$s" + "من يمكنه قراءة السجل" + "الأعضاء منذ دعوتهم" + "الأعضاء (السجل الكامل)" + "عناوين الغرف وسيلة للعثور على الغرف والوصول إليها. كما يضمن ذلك إمكانية مشاركة غرفتك بسهولة مع الآخرين. +يمكنك اختيار نشر غرفتك في دليل الغرف العام على خادمك المنزلي." + "نشر الغرفة" + "العناوين وسيلة للعثور على الغرف والمساحات والوصول إليها. كما يضمن ذلك إمكانية مشاركتها بسهولة مع الآخرين." + "الظهور" + "الأمان والخصوصية" + diff --git a/features/securityandprivacy/impl/src/main/res/values-es/translations.xml b/features/securityandprivacy/impl/src/main/res/values-es/translations.xml index f7e7812914..49547a939e 100644 --- a/features/securityandprivacy/impl/src/main/res/values-es/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-es/translations.xml @@ -2,9 +2,16 @@ "Necesitarás una dirección de sala para que sea visible en el directorio." "Editar dirección" + "Espacios donde los miembros pueden unirse a la sala sin invitación." + "Gestionar espacios" + "(Espacio desconocido)" + "Otros espacios de los que no eres miembro" + "Tus espacios" "Agregar dirección" + "Cualquiera en espacios autorizados puede unirse, pero el resto debe solicitar acceso." "Todos deben solicitar acceso." "Solicitar unirse" + "Cualquiera en %1$s puede unirse, pero el resto debe solicitar acceso." "Sí, activar cifrado" "Una vez activado, el cifrado de una sala no se puede desactivar. El historial de mensajes solo será visible para los miembros de la sala desde que fueron invitados o desde que se unieron a la sala. Nadie más que los miembros de la sala podrán leer los mensajes. Esto puede impedir que los bots y los puentes funcionen correctamente. @@ -15,21 +22,29 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr "Activar el cifrado de extremo a extremo" "Cualquiera puede unirse" "Cualquiera" + "Elige qué miembros de los espacios pueden unirse a esta sala sin una invitación. %1$s" + "Gestionar espacios" "Solo las personas invitadas pueden unirse." "Solo por invitación" "Acceso" + "Cualquiera en espacios autorizados puede unirse." + "Cualquiera en %1$s puede unirse." + "Miembros del espacio" "No se admiten los espacios por el momento." "Necesitarás una dirección de sala para que sea visible en el directorio." "Dirección" "Permite encontrar esta sala buscando en el directorio de salas públicas de %1$s" + "Permitir que sea encontrada al buscar en el directorio público." "Visible en el directorio público" "Cualquiera (el historial es público)" + "Los cambios no afectarán a los mensajes anteriores, solo a los nuevos. %1$s" "Quién puede leer el historial" - "Solo participantes desde que fueron invitados" - "Miembros (historia completa)" + "Miembros desde su invitación" + "Miembros (historial completo)" "Las direcciones de sala son formas de buscar salas y acceder a ellas. Esto también garantiza que puedas compartir fácilmente tu sala con otras personas. Puedes optar por publicar tu sala en el directorio de salas públicas de tu servidor base." "Publicación de la sala" + "Las direcciones son una forma de encontrar y acceder a salas y espacios. También garantizan que puedas compartirlos fácilmente con otras personas." "Visibilidad" "Seguridad y privacidad" diff --git a/features/securityandprivacy/impl/src/main/res/values-fa/translations.xml b/features/securityandprivacy/impl/src/main/res/values-fa/translations.xml index 6fc141b487..d4a92973cb 100644 --- a/features/securityandprivacy/impl/src/main/res/values-fa/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-fa/translations.xml @@ -1,21 +1,50 @@ - "نشانی اتاق" - "افزودن نشانی اتاق" + "برای اینکه بتوانید آن را در فهرست عمومی مشاهده کنید، به یک آدرس نیاز دارید." + "ویرایش آدرس" + "فضاهایی که اعضا می‌توانند بدون دعوت‌نامه به اتاق بپیوندند." + "مدیریت فضاها" + "(فضای ناشناخته)" + "فضاهای دیگری که عضو آنها نیستید" + "فضاهای شما" + "اضافه کردن آدرس" + "هر کسی که در فضاهای مجاز باشد می‌تواند عضو شود، اما بقیه باید درخواست دسترسی بدهند." + "همه باید درخواست دسترسی بدهند." "درخواست دعوت" + "هر کسی در %1$s می‌تواند عضو شود، اما بقیه باید درخواست دسترسی بدهند." "بله. به کار انداختن رمزنگاری" + "پس از فعال کردن، رمزگذاری برای یک اتاق قابل غیرفعال کردن نیست، تاریخچه پیام فقط برای اعضای اتاق از زمان دعوت یا از زمان پیوستن به اتاق قابل مشاهده خواهد بود. +هیچ کس غیر از اعضای اتاق قادر به خواندن پیام‌ها نخواهد بود. این ممکن است مانع از عملکرد صحیح ربات‌ها و پل‌ها شود. +ما فعال کردن رمزگذاری را برای اتاق‌هایی که هر کسی می‌تواند آنها را پیدا کند و به آنها بپیوندد، توصیه نمی‌کنیم." "رمزگذاری فعال شود؟" "پس از به کار افتادن، رمزنگاری قابل از کار انداختن نیست." "رمزنگاری" - "هرکسی می‌تواند یافته و بپیوندد" - "هرکسی" - "افراد فقط در صورت دعوت می‌توانند بپیوندند" - "فقط دعوتی" - "دسترسی اتاق" + "فعال کردن رمزنگاری سراسری" + "هر کسی می‌تواند عضو شود." + "هر کس" + "انتخاب کنید که اعضای کدام فضاها می‌توانند بدون دعوتنامه به این اتاق بپیوندند. %1$s" + "مدیریت فضاها" + "فقط افراد دعوت شده می‌توانند عضو شوند." + "فقط دعوت" + "دسترسی" + "هر کسی که در فضاهای مجاز باشد می‌تواند عضو شود." + "هر کسی که در %1$s باشد می‌تواند عضو شود." + "اعضای فضا" "در حال حاضر فضاها پشتیبانی نمی‌شوند" - "نشانی اتاق" + "برای اینکه بتوانید آن را در فهرست عمومی مشاهده کنید، به یک آدرس نیاز دارید." + "آدرس" + "با جستجو در فهرست اتاق‌های عمومی %1$s، امکان یافتن این اتاق را فراهم کنید." + "با جستجو در فهرست عمومی، می‌توان آن را پیدا کرد." + "قابل مشاهده در فهرست عمومی" "هرکسی" + "تغییرات روی پیام‌های قبلی تأثیری نخواهند داشت، فقط روی پیام‌های جدید اعمال می‌شوند. %1$s" + "چه کسی می‌تواند تاریخ بخواند؟" + "اعضا از زمان دعوت" + "اعضا (تاریخچه کامل)" + "آدرس‌های اتاق، راه‌هایی برای پیدا کردن و دسترسی به اتاق‌ها هستند. این همچنین تضمین می‌کند که می‌توانید به راحتی اتاق خود را با دیگران به اشتراک بگذارید. +شما می‌توانید اتاق خود را در دایرکتوری عمومی اتاق سرور خانگی خود منتشر کنید." "انتشار اتاق" - "نمایانی اتاق" + "آدرس‌ها راهی برای پیدا کردن و دسترسی به اتاق‌ها و فضاها هستند. این همچنین تضمین می‌کند که می‌توانید به راحتی آنها را با دیگران به اشتراک بگذارید." + "قابلیت مشاهده" "امنیت و محرمانگی" diff --git a/features/signedout/impl/src/main/res/values-ar/translations.xml b/features/signedout/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..fecfbc2b3a --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,8 @@ + + + "لقد غيّرت كلمة مرورك في جلسة أخرى" + "لقد حذفت هذه الجلسة من جلسة أخرى" + "أبطل مسؤول الخادم صلاحية وصولك" + "ربما تم تسجيل خروجك لأحد الأسباب المذكورة أدناه. يُرجى تسجيل الدخول مرة أخرى لمتابعة استخدام %s." + "تم تسجيل خروجك" + diff --git a/features/space/impl/src/main/res/values-ar/translations.xml b/features/space/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..346752f14e --- /dev/null +++ b/features/space/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,36 @@ + + + "اختيار المالكين" + "%1$s (مسؤول)" + + "مغادرة %1$d غرف والمساحة" + "مغادرة %1$d غرفة والمساحة" + "مغادرة %1$d غرفتين والمساحة" + "مغادرة %1$d غرف والمساحة" + "مغادرة %1$d غرفة والمساحة" + "مغادرة %1$d غرفة والمساحة" + + "اختر الغرف التي تريد مغادرتها، والتي لست المسؤول الوحيد عنها:" + "يجب أن تعيّن مسؤولًا آخر لهذه المساحة قبل أن تتمكن من المغادرة." + "أنت المالك الوحيد لـ %1$s. يجب أن تنقل الملكية إلى شخص آخر قبل المغادرة." + "لن تتم إزالتك من الغرف التالية لأنك المسؤول الوحيد:" + "مغادرة %1$s؟" + "أنت المسؤول الوحيد عن %1$s" + "نقل الملكية" + "غرفة" + "لن تؤثر إضافة غرفة على إمكانية الوصول إلى الغرفة. لتغيير الوصول، انتقل إلى إعدادات الغرفة > الأمان والخصوصية." + "أضف غرفتك الأولى" + "عرض الأعضاء" + "لن تؤثر إزالة غرفة على إمكانية الوصول إلى الغرفة. لتغيير الوصول، انتقل إلى معلومات الغرفة > الخصوصية والأمان." + + "إزالة %1$d غرف من %2$s" + "إزالة %1$d غرفة من %2$s" + "إزالة %1$d غرفتين من %2$s" + "إزالة %1$d غرف من %2$s" + "إزالة %1$d غرفة من %2$s" + "إزالة %1$d غرفة من %2$s" + + "مغادرة المساحة" + "الأدوار والصلاحيات" + "الأمان والخصوصية" + diff --git a/features/space/impl/src/main/res/values-es/translations.xml b/features/space/impl/src/main/res/values-es/translations.xml index 41887088b6..f65375f616 100644 --- a/features/space/impl/src/main/res/values-es/translations.xml +++ b/features/space/impl/src/main/res/values-es/translations.xml @@ -1,5 +1,28 @@ + "Elegir propietarios" + "%1$s (Admin.)" + + "Salir de %1$d sala y del espacio" + "Salir de %1$d salas y del espacio" + + "Elige las salas de las que quieras salir en las que no eres el único administrador:" + "Debes designar otro administrador para este espacio antes de poder salir." + "Eres el único propietario de %1$s. Debes transferir la propiedad a otra persona antes de salir." + "No se te eliminará de las siguientes salas porque eres el único administrador:" + "¿Salir de %1$s?" + "Eres el único administrador de %1$s" + "Transferencia de propiedad" + "Sala" + "Añadir una sala no afectará a su configuración de acceso. Para cambiar el acceso, ve a Ajustes de la sala > Seguridad y privacidad." + "Añade tu primera sala" + "Ver miembros" + "Eliminar una sala no afectará a su configuración de acceso. Para cambiar el acceso, ve a Información de la sala > Privacidad y seguridad." + + "Eliminar %1$d sala de %2$s" + "Eliminar %1$d salas de %2$s" + + "Salir del espacio" "Roles y permisos" "Seguridad y privacidad" diff --git a/features/space/impl/src/main/res/values-fa/translations.xml b/features/space/impl/src/main/res/values-fa/translations.xml index d8f377c0b2..9aa3818f44 100644 --- a/features/space/impl/src/main/res/values-fa/translations.xml +++ b/features/space/impl/src/main/res/values-fa/translations.xml @@ -2,11 +2,26 @@ "گزینش مالکان" "‏%1$s (مدیر)" + + "اتاق و فضای %1$d را ترک کنید" + "اتاق‌ها و فضای %1$d را ترک کنید" + + "اتاق‌هایی را که می‌خواهید از آنها خارج شوید و تنها مدیر آنها نیستید، انتخاب کنید:" "پیش از ترک باید مدیری دیگر به این فضا تخصیص دهید." + "شما تنها مالک %1$s هستید. قبل از ترک محل، باید مالکیت را به شخص دیگری منتقل کنید." "از اتاق(های) زیر برداشته نخواهید شد؛ چرا که تنها مدیر هستید:" "ترک %1$s؟" "تنها مدیر %1$s هستید" + "انتقال مالکیت" + "اتاق" + "اضافه کردن اتاق تاثیری بر دسترسی به اتاق نخواهد داشت. برای تغییر دسترسی، به تنظیمات اتاق > امنیت و حریم خصوصی بروید." + "اولین اتاق خود را اضافه کنید" "دیدن اعضا" + "حذف یک اتاق تاثیری بر دسترسی به آن نخواهد داشت. برای تغییر دسترسی، به اطلاعات اتاق > حریم خصوصی و امنیت بروید." + + "حذف اتاق %1$d از %2$s" + "حذف اتاق‌های %1$d از %2$s" + "ترک فضا" "نقش‌ها و مجوزها" "امنیت و محرمانگی" diff --git a/features/startchat/impl/src/main/res/values-ar/translations.xml b/features/startchat/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..b4e96844c4 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,12 @@ + + + "إنشاء غرفة جديدة" + "دليل الغرف" + "حدث خطأ أثناء محاولة بدء محادثة" + "الانضمام إلى غرفة باستخدام العنوان" + "العنوان غير صالح" + "أدخل العنوان…" + "تم العثور على غرفة مطابقة" + "لم يتم العثور على الغرفة" + "مثلاً: #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-es/translations.xml b/features/startchat/impl/src/main/res/values-es/translations.xml index 64a9f02a17..dd4233ce50 100644 --- a/features/startchat/impl/src/main/res/values-es/translations.xml +++ b/features/startchat/impl/src/main/res/values-es/translations.xml @@ -5,7 +5,7 @@ "Se ha producido un error al intentar iniciar un chat" "Unirse a una sala por su dirección" "Dirección no válida" - "Introducir…" + "Introducir dirección…" "Sala encontrada" "No se encontró la sala" "p. ej., #nombre-de-la-sala:matrix.org" diff --git a/features/startchat/impl/src/main/res/values-fa/translations.xml b/features/startchat/impl/src/main/res/values-fa/translations.xml index 7cb1e1baee..4a4cd239de 100644 --- a/features/startchat/impl/src/main/res/values-fa/translations.xml +++ b/features/startchat/impl/src/main/res/values-fa/translations.xml @@ -5,7 +5,7 @@ "هنگام تلاش برای شروع چت خطایی روی داد" "پیوستن به اتاق با نشانی" "نشانی معتبری نیست" - "ورود…" + "آدرس را وارد کنید…" "اتاق مطابق پیدا شد" "اتاق پیدا نشد" "نمونه: ‪#room-name:matrix.org" diff --git a/features/startchat/impl/src/main/res/values-fr/translations.xml b/features/startchat/impl/src/main/res/values-fr/translations.xml index 9aaebd018f..74a099f93a 100644 --- a/features/startchat/impl/src/main/res/values-fr/translations.xml +++ b/features/startchat/impl/src/main/res/values-fr/translations.xml @@ -5,7 +5,7 @@ "Une erreur s’est produite lors de la tentative de création de la discussion" "Saisir une adresse de salon" "Ce n’est pas une adresse valide" - "Saisir…" + "Adresse du salon…" "Ce salon existe" "Salon non trouvé" "ex: #nom-du-salon:matrix.org" diff --git a/features/userprofile/shared/src/main/res/values-ar/translations.xml b/features/userprofile/shared/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..2292e8cb70 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ar/translations.xml @@ -0,0 +1,19 @@ + + + "حظر" + "المستخدمون المحظورون لن يتمكّنوا من إرسال رسائل لك وكل رسائلهم ستُحجب. يمكنك إلغاء حظرهم متى ما أردت." + "حظر المستخدم" + "إلغاء الحظر" + "ستتمكن من رؤية كل رسائلهم مجدّداً." + "إلغاء حظر المستخدم" + "حظر" + "المستخدمون المحظورون لن يتمكّنوا من إرسال رسائل لك وكل رسائلهم ستُحجب. يمكنك إلغاء حظرهم متى ما أردت." + "حظر المستخدم" + "الملف الشخصي" + "إلغاء الحظر" + "ستتمكن من رؤية كل رسائلهم مجدّداً." + "إلغاء حظر المستخدم" + "استخدم تطبيق الويب للتحقق من هذا المستخدم." + "تحقّق من %1$s" + "حدث خطأ أثناء محاولة بدء محادثة" + diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt index 465fc20066..c97bf2cd2e 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt @@ -15,7 +15,6 @@ import com.freeletics.flowredux.dsl.FlowReduxStateMachine import io.element.android.features.verifysession.impl.util.andLogStateChange import io.element.android.features.verifysession.impl.util.logReceivedEvents import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.verification.SessionVerificationData @@ -23,6 +22,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.VerificationRequest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.timeout @@ -95,10 +95,13 @@ class OutgoingVerificationStateMachine( // If a key backup exists, wait until it's restored or a timeout happens val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse() if (hasBackup) { - tryOrNull { - encryptionService.recoveryStateStateFlow.filter { it == RecoveryState.ENABLED } + try { + encryptionService.recoveryStateStateFlow + .filter { it == RecoveryState.ENABLED || it == RecoveryState.DISABLED || it == RecoveryState.INCOMPLETE } .timeout(10.seconds) .first() + } catch (_: TimeoutCancellationException) { + // The backup was not restored in time, proceed to the Completed state anyway } } state.override { State.Completed.andLogStateChange() } diff --git a/features/verifysession/impl/src/main/res/values-ar/translations.xml b/features/verifysession/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..104dbdacfa --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,54 @@ + + + "لا يمكنك التأكيد؟" + "إنشاء مفتاح استرداد جديد" + "اختر طريقة التحقق لإعداد المراسلة الآمنة." + "تأكيد هويتك الرقمية" + "استخدام جهاز آخر" + "استخدام مفتاح الاسترداد" + "يمكنك الآن قراءة الرسائل أو إرسالها بأمان، ويمكن لأي شخص تدردش معه أن يثق بهذا الجهاز أيضًا." + "تم التحقق من الجهاز" + "استخدام جهاز آخر" + "في انتظار الجهاز الآخر…" + "شيء ما لا يبدو سليماً. إما مهلة الطلب قد انتهت أو أن الطلب رُفِض." + "تأكد من تطابق الرموز التعبيرية أدناه مع تلك الظاهرة في جلستك الأخرى." + "قارن الرموز التعبيرية" + "أكّد أن الرموز التعبيرية أدناه تطابق الرموز المعروضة على جهاز المستخدم الآخر." + "أكّد أن الأرقام أدناه تطابق الأرقام المعروضة في جلستك الأخرى." + "قارن الأرقام" + "تم التحقق من جلستك الحالية. فيها وصول إلى رسائلك المشفّرة والمستخدمون الآخرون يرونها موثوقة." + "يمكنك الآن الوثوق بالهوية الرقمية لهذا المستخدم عند إرسال الرسائل أو تلقيها." + "تم التحقق من الجهاز" + "أدخل مفتاح الاسترداد" + "إما أن الطلب انتهت مهلته، أو تم رفضه، أو حدث عدم تطابق في التحقق." + "أثبت هويتك ليصبح لديك وصول إلى رسائلك المشفّرة السابقة." + "افتح جلسة موجودة" + "إعادة محاولة عملية التحقق" + "جاهز" + "بانتظار التطابق" + "قارِن مجموعة فريدة من الرموز التعبيرية." + "قارن بين الرموز التعبيرية المميزة، متأكداً من ورودها في نفس الترتيب." + "تم تسجيل الدخول" + "إما أن الطلب انتهت مهلته، أو تم رفضه، أو حدث عدم تطابق في التحقق." + "فشل التحقق" + "لا تتابع إلا إذا كنت أنت من بدأ هذا التحقق." + "تحقّق من الجهاز الآخر للحفاظ على أمان سجل رسائلك." + "تم التحقق من جلستك الحالية. فيها وصول إلى رسائلك المشفّرة والمستخدمون الآخرون يرونها موثوقة." + "تم التحقق من الجهاز" + "تم طلب التحقق" + "لا يتطابقون" + "يتطابقون" + "تأكّد من أن التطبيق مفتوح على الجهاز الآخر قبل بدء التحقق من هنا." + "افتح التطبيق على جهاز آخر تم التحقق منه" + "لمزيد من الأمان، تحقّق من هذا المستخدم بمقارنة مجموعة من الرموز التعبيرية على أجهزتكما. افعل ذلك باستخدام وسيلة موثوقة للتواصل." + "هل تريد التحقق من هذا المستخدم؟" + "لمزيد من الأمان، يريد مستخدم آخر التحقق من هويتك الرقمية. ستُعرض لك مجموعة من الرموز التعبيرية لمقارنتها." + "من المفترض أن تظهر نافذة منبثقة على الجهاز الآخر. ابدأ التحقق من هناك الآن." + "ابدأ التحقق على الجهاز الآخر" + "ابدأ التحقق على الجهاز الآخر" + "في انتظار المستخدم الآخر" + "بمجرد القبول، ستتمكن من متابعة التحقق." + "اِقبل طلب بدء عملية التحقق في جلستك الأخرة للاستمرار." + "بانتظار قبول الطلب" + "تجري إزالة الجهاز…" + diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml index 52422393bf..de23ddc217 100644 --- a/features/verifysession/impl/src/main/res/values-es/translations.xml +++ b/features/verifysession/impl/src/main/res/values-es/translations.xml @@ -2,8 +2,8 @@ "¿No puedes confirmar?" "Crear una nueva clave de recuperación" - "Verifica este dispositivo para configurar la mensajería segura." - "Confirma que eres tú" + "Elige cómo verificar para configurar la mensajería segura." + "Confirma tu identidad digital" "Usar otro dispositivo" "Usar clave de recuperación" "Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo." @@ -17,7 +17,7 @@ "Confirma que los números que aparecen a continuación coinciden con los mostrados en tu otra sesión." "Comparar números" "Ahora puedes leer o enviar mensajes de forma segura en tu otro dispositivo." - "Ahora puedes confiar en la identidad de este usuario al enviar o recibir mensajes." + "Ahora puedes confiar en la identidad digital de este usuario al enviar o recibir mensajes." "Dispositivo verificado" "Introduce la clave de recuperación" "O bien se agotó el tiempo de solicitud, se rechazó la solicitud o hubo una discrepancia en la verificación." @@ -42,7 +42,7 @@ "Abre la aplicación en otro dispositivo verificado" "Para mayor seguridad, verifica a este usuario comparando un conjunto de emojis en vuestros dispositivos. Hazlo utilizando una forma de comunicación de confianza." "¿Verificar a este usuario?" - "Para mayor seguridad, otro usuario quiere verificar tu identidad. Se te mostrará un conjunto de emojis para compararlos." + "Para mayor seguridad, otro usuario quiere verificar tu identidad digital. Se te mostrará un conjunto de emojis para compararlos." "Deberías ver una ventana emergente en el otro dispositivo. Inicia ahora la verificación desde allí." "Iniciar la verificación en el otro dispositivo" "Iniciar la verificación en el otro dispositivo" @@ -50,5 +50,5 @@ "Una vez aceptada, podrás continuar con la verificación." "Acepta la solicitud para iniciar el proceso de verificación en tu otra sesión para continuar." "A la espera de aceptar la solicitud" - "Cerrando sesión…" + "Eliminando dispositivo…" diff --git a/features/verifysession/impl/src/main/res/values-fa/translations.xml b/features/verifysession/impl/src/main/res/values-fa/translations.xml index d68ec29229..5f2b02584c 100644 --- a/features/verifysession/impl/src/main/res/values-fa/translations.xml +++ b/features/verifysession/impl/src/main/res/values-fa/translations.xml @@ -13,11 +13,14 @@ "يه چيزي درست به نظر نمياد یا زمان درخواست به پایان رسید یا درخواست رد شد." "تأیید تطابق شکلک‌های زیر با شکلک‌های نشان داده شده روی افزارهٔ دیگرتان." "مقایسهٔ شکلک‌ها" + "تأیید کنید که ایموجی‌های زیر با ایموجی‌های نمایش داده شده در دستگاه کاربر دیگر مطابقت دارند." "تأیید کنید که اعداد زیر با اعداد نشان داده شده در جلسه دیگر شما مطابقت دارند." "مقایسهٔ اعداد" "اکنون می‌توانید روی افزارهٔ دیگرتان با امنیت پیام فرستاده و بخوانید." + "اکنون می‌توانید هنگام ارسال یا دریافت پیام، به هویت دیجیتال این کاربر اعتماد کنید." "افزاره تأیید شده" "ورود کلید بازیابی" + "یا مهلت درخواست تمام شده، یا درخواست رد شده، یا عدم تطابق تأیید وجود داشته است." "برای دسترسی به تاریخچه پیام‌های رمزگذاری‌شده‌تان، ثابت کنید که خودتان هستید." "گشودن نشستی موجود" "تلاش برای تأیید دوباره" @@ -26,11 +29,25 @@ "مقایسهٔ مجموعه‌ای یکتا از شکلک‌ها." "شکلک‌ها را مقایسه کنید، از ترتیب نمایش آنان نیز مطمئن شوید." "وارد شده" + "یا مهلت درخواست تمام شده، یا درخواست رد شده، یا عدم تطابق تأیید وجود داشته است." "صحت‌سنجی شکست خورد" + "فقط در صورتی ادامه دهید که خودتان این تأیید را آغاز کرده باشید." + "برای ایمن نگه داشتن تاریخچه پیام‌هایتان، دستگاه دیگر را تأیید کنید." "اکنون می‌توانید روی افزارهٔ دیگرتان با امنیت پیام فرستاده و بخوانید." "افزاره تأیید شده" + "درخواست تأیید" "مطابق نیستند" "مطابقند" + "قبل از شروع تأیید از اینجا، مطمئن شوید که برنامه را در دستگاه دیگر باز کرده‌اید." + "برنامه را روی دستگاه تأیید شده دیگری باز کنید" + "برای امنیت بیشتر، با مقایسه مجموعه‌ای از ایموجی‌ها در دستگاه‌های خود، این کاربر را تأیید کنید. این کار را با استفاده از یک روش ارتباطی مطمئن انجام دهید." + "این کاربر را تایید می‌کنید؟" + "برای امنیت بیشتر، اگر کاربر دیگری بخواهد هویت دیجیتالی شما را تأیید کند، مجموعه‌ای از ایموجی‌ها برای مقایسه به شما نشان داده می‌شود." + "باید یک پنجره‌ی پاپ‌آپ روی دستگاه دیگر ببینید. اکنون تأیید را از آنجا شروع کنید." + "شروع تأیید در دستگاه دیگر" + "شروع تأیید در دستگاه دیگر" + "در انتظار کاربر دیگر" + "پس از پذیرش، می‌توانید به تأیید ادامه دهید." "برای ادامه، درخواست شروع فرآیند تأیید را در جلسه دیگر خود بپذیرید." "منظر پذیرش درخواست" "برداشتن افزاره…" diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt index c6cbfca281..577683d12e 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus @@ -216,6 +217,85 @@ class OutgoingVerificationPresenterTest { } } + @Test + fun `present - When verification is approved and a backup exists but recovery is disabled, the flow completes`() = runTest { + val service = unverifiedSessionService( + requestUserVerificationLambda = { }, + startSasVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val encryptionService = FakeEncryptionService().apply { + givenDoesBackupExistOnServerResult(Result.success(true)) + recoveryStateStateFlow.value = RecoveryState.DISABLED + } + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingUserVerificationRequest(), + encryptionService = encryptionService, + ) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java) + service.emitVerificationFlowState(VerificationFlowState.DidFinish) + assertThat(awaitItem().step).isEqualTo(Step.Completed) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - When verification is approved and a backup exists but recovery is incomplete, the flow completes`() = runTest { + val service = unverifiedSessionService( + requestUserVerificationLambda = { }, + startSasVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val encryptionService = FakeEncryptionService().apply { + givenDoesBackupExistOnServerResult(Result.success(true)) + recoveryStateStateFlow.value = RecoveryState.INCOMPLETE + } + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingUserVerificationRequest(), + encryptionService = encryptionService, + ) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java) + service.emitVerificationFlowState(VerificationFlowState.DidFinish) + assertThat(awaitItem().step).isEqualTo(Step.Completed) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - When verification is approved but the recovery state is not determined, the flow completes after the timeout`() = runTest { + val service = unverifiedSessionService( + requestUserVerificationLambda = { }, + startSasVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val encryptionService = FakeEncryptionService().apply { + givenDoesBackupExistOnServerResult(Result.success(true)) + } + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingUserVerificationRequest(), + encryptionService = encryptionService, + ) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java) + service.emitVerificationFlowState(VerificationFlowState.DidFinish) + // The recovery state never leaves UNKNOWN, so the 10 seconds timeout fires and the flow completes anyway + this@runTest.advanceUntilIdle() + assertThat(awaitItem().step).isEqualTo(Step.Completed) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - When verification is declined, the flow is canceled`() = runTest { val service = unverifiedSessionService( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17eb8ff751..1e1751f3bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,12 +17,12 @@ constraintlayout = "2.2.2" constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" -media3 = "1.10.1" +media3 = "1.11.0" camera = "1.6.1" work = "2.11.2" # Compose -compose_bom = "2026.06.01" +compose_bom = "2026.08.00" # Coroutines coroutines = "1.11.0" @@ -123,7 +123,7 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_startup = "androidx.startup:startup-runtime:1.2.0" androidx_preference = "androidx.preference:preference:1.2.1" -androidx_webkit = "androidx.webkit:webkit:1.16.0" +androidx_webkit = "androidx.webkit:webkit:1.17.0" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha23' } @@ -175,7 +175,7 @@ test_truth = "com.google.truth:truth:1.4.5" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" test_robolectric = "org.robolectric:robolectric:4.16.1" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } -test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.1" +test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.2" test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } @@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.13" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.19" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -215,9 +215,9 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.2" -maplibre = "org.maplibre.gl:android-sdk:13.4.1" +maplibre = "org.maplibre.gl:android-sdk:13.5.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" -maplibre_compose = "org.maplibre.compose:maplibre-compose:0.13.1" +maplibre_compose = "org.maplibre.compose:maplibre-compose:0.14.0" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:3.1.1" @@ -230,7 +230,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.56.4" -sentry = "io.sentry:sentry-android:8.52.0" +sentry = "io.sentry:sentry-android:8.53.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 84c5a9c661..760c45969c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/libraries/androidutils/src/main/res/values-ar/translations.xml b/libraries/androidutils/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..6449b128d1 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ar/translations.xml @@ -0,0 +1,4 @@ + + + "لم يُعثر على تطبيق ليتعامل مع هذا الحدث." + diff --git a/libraries/audio/impl/build.gradle.kts b/libraries/audio/impl/build.gradle.kts index 419e81962c..c0db3c4f86 100644 --- a/libraries/audio/impl/build.gradle.kts +++ b/libraries/audio/impl/build.gradle.kts @@ -1,4 +1,5 @@ import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright (c) 2025 Element Creations Ltd. @@ -22,4 +23,6 @@ dependencies { implementation(libs.androidx.corektx) implementation(projects.libraries.di) + + testCommonDependencies(libs) } diff --git a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt index bc6a42d2f9..a9bc6fdbfd 100644 --- a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt +++ b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt @@ -48,9 +48,8 @@ class DefaultAudioFocus( } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - // For recording, ignore transient focus losses (e.g., notification sounds). - // The AudioRecord API keeps capturing regardless. - if (requester != AudioFocusRequester.RecordVoiceMessage) { + Timber.d("AudioFocus: transient loss ($it)") + if (requester.pausesOnTransientFocusLoss()) { onFocusLost() } } @@ -96,8 +95,8 @@ class DefaultAudioFocus( private fun AudioFocusRequester.toAudioUsage(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage, AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION + AudioFocusRequester.VoiceMessage, AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA } } @@ -105,12 +104,22 @@ private fun AudioFocusRequester.toAudioUsage(): Int { private fun AudioFocusRequester.toAudioStream(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage, AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL + AudioFocusRequester.VoiceMessage, AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC } } +private fun AudioFocusRequester.pausesOnTransientFocusLoss(): Boolean { + return when (this) { + // The AudioRecord API keeps capturing regardless. + AudioFocusRequester.RecordVoiceMessage, + AudioFocusRequester.VoiceMessage -> false + AudioFocusRequester.ElementCall, + AudioFocusRequester.MediaViewer -> true + } +} + private fun AudioFocusRequester.willPausedWhenDucked(): Boolean { return when (this) { // (note that for Element Call, there is no action when the focus is lost) diff --git a/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt new file mode 100644 index 0000000000..d0e255dd41 --- /dev/null +++ b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * Copyright 2026 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.libraries.audio.impl + +import android.media.AudioAttributes +import android.media.AudioManager +import androidx.core.content.getSystemService +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +class DefaultAudioFocusTest : RobolectricTest() { + @Test + fun `voice message playback requests focus for media, not for a call`() { + assertThat(usageOf(AudioFocusRequester.VoiceMessage)).isEqualTo(AudioAttributes.USAGE_MEDIA) + } + + @Test + fun `media viewer playback requests focus for media`() { + assertThat(usageOf(AudioFocusRequester.MediaViewer)).isEqualTo(AudioAttributes.USAGE_MEDIA) + } + + @Test + fun `voice message recording requests focus for voice communication`() { + assertThat(usageOf(AudioFocusRequester.RecordVoiceMessage)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION) + } + + @Test + fun `element call requests focus for voice communication`() { + assertThat(usageOf(AudioFocusRequester.ElementCall)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION) + } + + @Test + fun `a transient focus loss does not pause a voice message`() { + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse() + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)).isFalse() + } + + @Test + fun `a permanent focus loss pauses a voice message`() { + assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS)).isTrue() + } + + @Test + fun `a transient focus loss does not stop a voice message recording`() { + assertThat(losesFocusOn(AudioFocusRequester.RecordVoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse() + } + + @Test + fun `a transient focus loss still pauses the media viewer`() { + assertThat(losesFocusOn(AudioFocusRequester.MediaViewer, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isTrue() + } + + private fun losesFocusOn(requester: AudioFocusRequester, focusChange: Int): Boolean { + val context = RuntimeEnvironment.getApplication() + val audioManager = requireNotNull(context.getSystemService()) + var focusLost = false + DefaultAudioFocus(context).requestAudioFocus(requester) { focusLost = true } + val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest) + request.listener.onAudioFocusChange(focusChange) + return focusLost + } + + private fun usageOf(requester: AudioFocusRequester): Int { + val context = RuntimeEnvironment.getApplication() + val audioManager = requireNotNull(context.getSystemService()) + DefaultAudioFocus(context).requestAudioFocus(requester) {} + val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest) + return requireNotNull(request.audioFocusRequest).audioAttributes.usage + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index fe563138ad..87d6350c65 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.core.extensions +import java.text.Normalizer import java.util.Locale fun Boolean.toOnOff() = if (this) "ON" else "OFF" @@ -114,3 +115,14 @@ fun String.toSafeLength( this } } + +/** + * Remove accents and replace special characters by ASCII from the string. + * For instance, "é" will be replaced by "e", "ç" by "c", etc. + */ +fun String.normalized(): String { + // Normalize the string. + return Normalizer.normalize(this, Normalizer.Form.NFD) + // then keep only ASCII characters and replace non-ASCII characters with an empty string. + .replace("[^\\p{ASCII}]".toRegex(), "") +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt index 666ee1d7ab..91402e3d11 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt @@ -20,12 +20,13 @@ fun String.isValidUrl(): Boolean { } /** - * Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty + * Ensure string starts with "http" (case-insensitively, so "HTTP://" / "HTTPS://" are respected). If it is + * not the case, "https://" is added, only if the String is not empty. */ fun String.ensureProtocol(): String { return when { isEmpty() -> this - !startsWith("http") -> "https://$this" + !startsWith("http", ignoreCase = true) -> "https://$this" else -> this } } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt index 0099a67a79..c3cae14843 100644 --- a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt @@ -74,4 +74,10 @@ class BasicExtensionsTest { val result = textWithDirectionOverrides.filterDirectionOverrides() assertEquals("123456789", result) } + + @Test + fun `normalized removes accents and replace special characters from text`() { + assertEquals("Stefan", "Ștefan".normalized()) + assertEquals("ca enleve etre", "ça enlève être".normalized()) + } } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt new file mode 100644 index 0000000000..9a9d68dcaa --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/uri/UrlUtilsTest.kt @@ -0,0 +1,35 @@ +/* + * 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.libraries.core.uri + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UrlUtilsTest { + @Test + fun `ensureProtocol keeps an empty string unchanged`() { + assertThat("".ensureProtocol()).isEqualTo("") + } + + @Test + fun `ensureProtocol prepends https when there is no scheme`() { + assertThat("matrix.org".ensureProtocol()).isEqualTo("https://matrix.org") + } + + @Test + fun `ensureProtocol keeps an existing http or https scheme`() { + assertThat("https://matrix.org".ensureProtocol()).isEqualTo("https://matrix.org") + assertThat("http://matrix.org".ensureProtocol()).isEqualTo("http://matrix.org") + } + + @Test + fun `ensureProtocol keeps an uppercase scheme rather than double-prepending`() { + assertThat("HTTPS://matrix.org".ensureProtocol()).isEqualTo("HTTPS://matrix.org") + assertThat("Http://matrix.org".ensureProtocol()).isEqualTo("Http://matrix.org") + } +} diff --git a/libraries/dateformatter/impl/src/main/res/values-ar/translations.xml b/libraries/dateformatter/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..9e8f6c94d4 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s في %2$s" + "هذا الشهر" + diff --git a/libraries/emoji/impl/src/main/res/values-ar/translations.xml b/libraries/emoji/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..5a84323379 --- /dev/null +++ b/libraries/emoji/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,13 @@ + + + "أنشطة" + "أعلام" + "أكل وشرب" + "حيوانات وطبيعة" + "أشياء" + "الابتسامات والأشخاص" + "سفر وأماكن" + "الرموز التعبيرية الأخيرة" + "رموز" + "البحث عن رموز تعبيرية" + diff --git a/libraries/emoji/impl/src/main/res/values-es/translations.xml b/libraries/emoji/impl/src/main/res/values-es/translations.xml index 7a694296b2..d32846b145 100644 --- a/libraries/emoji/impl/src/main/res/values-es/translations.xml +++ b/libraries/emoji/impl/src/main/res/values-es/translations.xml @@ -7,5 +7,7 @@ "Objetos" "Emojis y personas" "Viajes y lugares" + "Emojis recientes" "Símbolos" + "Buscar emojis" diff --git a/libraries/emoji/impl/src/main/res/values-pl/translations.xml b/libraries/emoji/impl/src/main/res/values-pl/translations.xml index c59f2c6b54..9ac9d5dff9 100644 --- a/libraries/emoji/impl/src/main/res/values-pl/translations.xml +++ b/libraries/emoji/impl/src/main/res/values-pl/translations.xml @@ -4,7 +4,7 @@ "Flagi" "Jedzenie i napoje" "Zwierzęta i natura" - "Obiekty" + "Przedmioty" "Buźki i osoby" "Podróż i miejsca" "Ostatnie emoji" diff --git a/libraries/eventformatter/impl/src/main/res/values-ar/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..8b64ef4f6c --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,73 @@ + + + "(الصورة غُيِّرت أيضاً)" + "غيّرَ %1$s صورته الشخصية" + "قمتَ بتغيير صورتك الشخصية" + "تم خفض رتبة %1$s إلى عضو" + "تم خفض رتبة %1$s إلى مشرف" + "غيّر %1$s اسمه من %2$s إلى %3$s" + "غيّرتَ اسمك من %1$s إلى %2$s" + "أزال %1$s اسمه (كان %2$s سابقاً)" + "قمتَ بحذف اسمك (كان %1$s سابقاً)" + "عيّن %1$s اسمه ليكون %2$s" + "عيّنتَ اسمك ليكون %1$s" + "تمت ترقية %1$s إلى مسؤول" + "تمت ترقية %1$s إلى مشرف" + "غيّر %1$s صورة الغرفة" + "قمتَ بتغيير صورة الغرفة" + "أزال %1$s صورة الغرفة" + "قمتَ بإزالة صورة الغرفة" + "%1$s حظر %2$s" + "قمتَ بحظر %1$s" + "حظرت %1$s: %2$s" + "قام %1$s بحظر %2$s: %3$s" + "%1$s أنشأ الغرفة" + "قمتَ بإنشاء الغرفة" + "%1$s دعا %2$s" + "قَبِل %1$s الدعوة" + "قمتَ بقبول الدعوة" + "قمتَ بدعوة %1$s" + "قام %1$s بدعوتك" + "انضمَّ %1$s إلى الغرفة" + "اِنضممت إلى الغرفة" + "طلب %1$s الانضمام" + "سمح %1$s لـ %2$s بالانضمام" + "قمتَ بالسماح لـ %1$s بالانضمام" + "قمتَ بطلب الانضمام" + "رفض %1$s طلب %2$s بالانضمام" + "قمتَ برفض طلب %1$s للانضمام" + "رفض %1$s طلب انضمامك" + "لم يعد %1$s مهتماً بالانضمام" + "قمتَ بإلغاء طلب انضمامك" + "غادر %1$s المجموعة" + "قمتَ بمغادرة المجموعة" + "غيّر %1$s اسم الغرفة إلى: %2$s" + "قمتَ بتغيير اسم الغرفة إلى: %1$s" + "حذف %1$s اسم الغرفة" + "قمتَ بحذف اسم الغرفة" + "%1$s لم يُجرِ أي تغييرات" + "لم تُجرِ أي تغييرات" + "غيّر %1$s الرسائل المثبّتة" + "غيّرت الرسائل المثبّتة" + "ثبّت %1$s رسالة" + "قمت بتثبيت رسالة" + "ألغى %1$s تثبيت رسالة" + "قمت بإلغاء تثبيت رسالة" + "رفض %1$s الدعوة" + "قمتَ برفض الدعوة" + "%1$s طرد %2$s" + "قمتَ بطرد %1$s" + "أزلت %1$s: %2$s" + "قام %1$s بإزالة %2$s: %3$s" + "أرسل %1$s دعوة إلى %2$s لينضم إلى الغرفة" + "قمتَ بإرسال دعوة إلى %1$s لينضم إلى الغرفة" + "تراجع %1$s عن دعوة %2$s للانضمام إلى الغرفة" + "قمتَ بالتراجع عن دعوة %1$s للانضمام إلى الغرفة" + "غيّر %1$s الموضوع إلى: %2$s" + "قمتَ بتغيير الموضوع إلى: %1$s" + "أزال %1$s موضوع الغرفة" + "قمتَ بإزالة موضوع الغرفة" + "%1$s فكّ الحظر عن %2$s" + "قمتَ بفك الحظر عن %1$s" + "أحدث %1$s تغييراً غير معلوم في عضويته" + diff --git a/libraries/eventformatter/impl/src/main/res/values-fa/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fa/translations.xml index f7fd635d31..259bed9e5e 100644 --- a/libraries/eventformatter/impl/src/main/res/values-fa/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-fa/translations.xml @@ -19,6 +19,8 @@ "چهرک اتاق را برداشتید" "%2$s به دست %1$s مسدود کرد" "%1$s را مسدود کردید" + "شما %1$s: %2$s را محروم کردید" + "%1$s محروم شد %2$s: %3$s" "%1$s اتاق را ایجاد کرد" "اتاق را ساختید" "%1$s از %2$s دعوت کرد" @@ -55,6 +57,8 @@ "دعوت را رد کردید" "%2$s به دست %1$s برداشته شد" "%1$s را برداشتید" + "شما %1$s: %2$s را حذف کردید" + "%1$s حذف شد %2$s: %3$s" "%1$s دعوتی برای پیوستن %2$s به اتاق فرستاد" "برای %1$s دعوت پیوستن به اتاق فرستادید" "%1$s دعوت پیوستن به اتاق %2$s را باطل کرد" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt similarity index 63% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt index a6fb234b86..611bf7bf18 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/ClientUrlContentFetcher.kt @@ -8,9 +8,19 @@ package io.element.android.libraries.matrix.api /** - * An interface to fetch content from a URL using a simple GET request. + * An interface to fetch content from a URL using a simple GET request launched from a Matrix client. */ -fun interface UrlContentFetcher { +interface ClientUrlContentFetcher { + /** + * The domain of the public server for this homeserver. + */ + val server: String? + + /** + * The URL of the actual Matrix homeserver. + */ + val homeserverUrl: String + /** * Execute generic GET requests through the SDKs internal HTTP client. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index b83d96af96..3f98dcc509 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -60,16 +60,13 @@ import java.util.Optional * One instance exists per session, created on login or session restore and torn down by [logout], [clearCache] or account deactivation. * Suspending members here report failures through a [Result] instead of throwing, and run their work off the main thread. */ -interface MatrixClient : UrlContentFetcher { +interface MatrixClient : ClientUrlContentFetcher { /** Identifier of the logged in user; a [SessionId] and a [UserId] are the same value. */ val sessionId: SessionId /** Identifier of this device, as assigned by the homeserver when the session was created. */ val deviceId: DeviceId - /** Base URL of the homeserver this client is connected to. */ - val homeserverUrl: String - /** Directories holding this session's databases, caches and downloaded media. */ val sessionPaths: SessionPaths @@ -136,6 +133,9 @@ interface MatrixClient : UrlContentFetcher { /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */ val contentScanner: ContentScanner? + /** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */ + val isShuttingDown: Boolean + /** * Returns the room with the given id if the user has joined it, along with its live [Timeline]. * A new instance is created on each call and the caller owns it, so it must be closed once no longer needed. @@ -245,6 +245,9 @@ interface MatrixClient : UrlContentFetcher { /** Whether the homeserver advertises support for user status (MSC4426). */ suspend fun isUserStatusSupported(): Result + /** Whether the homeserver advertises support for the Profiles sliding sync extension (MSC4262). */ + suspend fun isProfilesSlidingSyncExtensionSupported(): Result + /** * Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. * diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt index 1e3ffea154..5013362f85 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt @@ -15,4 +15,4 @@ import java.lang.AutoCloseable * Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated * with it, such as temporary files. */ -interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable +interface TemporaryMatrixClient : ClientUrlContentFetcher, AutoCloseable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt index 3bd22c64f3..c3244b4742 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt @@ -12,10 +12,10 @@ package io.element.android.libraries.matrix.api */ interface TemporaryMatrixClientFactory { /** - * Builds an unauthenticated client pointing at [homeServerUrl], backed by a fresh set of temporary session directories. + * Builds an unauthenticated client pointing at [serverName], backed by a fresh set of temporary session directories. * The caller owns the returned client and must close it to delete those directories. * - * @param homeServerUrl the URL of the homeserver the client will talk to. + * @param serverName the domain of the public-facing server for the actual homeserver */ - suspend fun create(homeServerUrl: String): Result + suspend fun create(serverName: String): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt index 2c11c750e4..0ed3f8f2a2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt @@ -7,7 +7,7 @@ package io.element.android.libraries.matrix.api.scanner -import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.core.SessionId /** @@ -26,6 +26,6 @@ fun interface ContentScannerUrlProvider { /** * @param urlContentFetcher used to read the configuration advertised by the homeserver; a client that is not authenticated yet also works. */ - fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider + fun create(urlContentFetcher: ClientUrlContentFetcher): ContentScannerUrlProvider } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 0ff563a204..4be384d923 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -135,6 +135,7 @@ import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File import java.util.Optional +import java.util.concurrent.atomic.AtomicBoolean import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -162,6 +163,7 @@ class RustMatrixClient( ) : MatrixClient { override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) + override val server: String? = innerClient.server() override val homeserverUrl: String = innerClient.homeserver() override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId") private val sessionDispatcher = dispatchers.io.limitedParallelism(64) @@ -304,6 +306,10 @@ class RustMatrixClient( .buffer(Channel.UNLIMITED) .stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf()) + private val _isShuttingDown = AtomicBoolean(false) + override val isShuttingDown: Boolean + get() = _isShuttingDown.get() + init { // Make sure the session delegate has a reference to the client to be able to logout on auth error sessionDelegate.bindClient(this) @@ -320,7 +326,9 @@ class RustMatrixClient( } private suspend fun setupUserProfile() { - val supported = isUserStatusSupported().getOrDefault(false) + // Subscribing to own profile updates only requires the Profiles sliding sync extension, + // not the full user status capability (which also needs the status profile field to be settable). + val supported = isProfilesSlidingSyncExtensionSupported().getOrDefault(false) if (supported) { // No need to seed the data here, it's already stored by the sdk. ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener) @@ -530,6 +538,12 @@ class RustMatrixClient( } } + override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.isProfilesSlidingSyncExtensionSupported() + } + } + override fun enableAutomaticCallStatus(enabled: Boolean) { innerClient.enableAutomaticCallStatus(enabled) } @@ -666,11 +680,13 @@ class RustMatrixClient( } override suspend fun clearCache() { + _isShuttingDown.set(true) innerClient.clearCaches(innerSyncService) destroy() } override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) { + _isShuttingDown.set(true) sessionCoroutineScope.cancel() // Remove current delegate so we don't receive an auth error clientDelegateTaskHandle?.cancelAndDestroy() @@ -708,6 +724,8 @@ class RustMatrixClient( } override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = withContext(sessionDispatcher) { + _isShuttingDown.set(true) + Timber.w("Deactivating account") // Remove current delegate so we don't receive an auth error clientDelegateTaskHandle?.cancelAndDestroy() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt index 54e4f696d3..f5b1f2ba4e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt @@ -22,6 +22,9 @@ class RustTemporaryMatrixClient( client.getUrl(url) }.mapFailure { it.mapClientException() } + override val server: String? = client.server() + override val homeserverUrl: String = client.homeserver() + override fun close() { client.close() paths?.let { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt index 70903ba91a..0e7d8bf2c5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt @@ -19,7 +19,7 @@ class RustTemporaryMatrixClientFactory( private val sessionPathsFactory: SessionPathsFactory, private val rustMatrixClientFactory: RustMatrixClientFactory, ) : TemporaryMatrixClientFactory { - override suspend fun create(homeServerUrl: String): Result { + override suspend fun create(serverName: String): Result { return runCatchingExceptions { val sessionPaths = sessionPathsFactory.create() val client = rustMatrixClientFactory.getBaseClientBuilder( @@ -28,7 +28,7 @@ class RustTemporaryMatrixClientFactory( slidingSyncType = ClientBuilderSlidingSync.Native, isMessageSearchAvailable = false, ) - .homeserverUrl(homeServerUrl) + .serverName(serverName) .build() RustTemporaryMatrixClient(client, sessionPaths) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index fac5227f6a..6a06ac0599 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -29,6 +29,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException { is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message) is ClientBuildException.EventCache -> AuthenticationException.Generic(message) is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message) + is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message) } is OAuthException -> when (this) { is OAuthException.Generic -> AuthenticationException.OAuth(message) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 2c936c9b23..153aa6d1d4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -278,7 +278,6 @@ class RustMatrixAuthenticationService( .let { enterpriseService.tweakMasUrl( url = it, - homeserver = client.server() ?: client.homeserver(), urlContentFetcher = getUrlResolver, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 46fa885d25..569714181b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -13,9 +13,9 @@ import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.UrlContentFetcher import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -111,7 +111,7 @@ object SessionMatrixModule { } @Provides - fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher { + fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher { return matrixClient } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt index e59530e874..a5f372d7d1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt @@ -8,11 +8,13 @@ package io.element.android.libraries.matrix.impl.permalink +import android.net.Uri import androidx.core.net.toUri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -51,6 +53,10 @@ class DefaultPermalinkParser( matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri) } + matrixToUri.userIdWithSlash()?.let { userId -> + return PermalinkData.UserLink(userId = userId) + } + val result = runCatchingExceptions { parseMatrixEntityFrom(matrixToUri.toString()) }.getOrNull() @@ -84,3 +90,17 @@ class DefaultPermalinkParser( } } } + +/** + * A slash is allowed in the local part of a user id, but it also separates an identifier from an + * event id in a permalink, so [parseMatrixEntityFrom] cannot tell the two apart. Only a user id can + * start with an `@`, so recover it here rather than letting the ambiguity reach the SDK. + */ +private fun Uri.userIdWithSlash(): UserId? { + val identifier = fragment + ?.removePrefix("/") + ?.substringBefore('?') + ?: return null + if (!identifier.startsWith("@") || !identifier.contains('/')) return null + return identifier.takeIf { MatrixPatterns.isUserId(it) }?.let(::UserId) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index bd756bc002..c6a737e156 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -75,6 +75,7 @@ import org.matrix.rustcomponents.sdk.PollData import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle import org.matrix.rustcomponents.sdk.UploadParameters import org.matrix.rustcomponents.sdk.UploadSource +import org.matrix.rustcomponents.sdk.createCaptionEdit import org.matrix.rustcomponents.sdk.use import timber.log.Timber import uniffi.matrix_sdk.PaginationStatus @@ -341,7 +342,7 @@ class RustTimeline( formattedCaption: String?, ): Result = withContext(dispatcher) { runCatchingExceptions { - val editedContent = EditedContent.MediaCaption( + val editedContent = createCaptionEdit( caption = caption, formattedCaption = formattedCaption?.let { FormattedBody(body = it, format = MessageFormat.Html) @@ -365,7 +366,7 @@ class RustTimeline( fromNotification: Boolean, msgType: MsgType, ): Result = withContext(dispatcher) { - runCatchingExceptions { + runCatchingExceptions { val msg = MessageEventContent.from( body = body, htmlBody = htmlBody, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt index 322103042b..23e0cf2302 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt @@ -16,7 +16,6 @@ import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client -import org.matrix.rustcomponents.sdk.NoHandle import java.io.File import kotlin.io.path.createTempDirectory import org.matrix.rustcomponents.sdk.ClientException as RustClientException @@ -84,7 +83,7 @@ class RustTemporaryMatrixClientTest { } private fun createRustTemporaryMatrixClient( - client: Client = Client(NoHandle), + client: Client = FakeFfiClient(), paths: SessionPaths, ): RustTemporaryMatrixClient { return RustTemporaryMatrixClient(client, paths) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 13be3cf2d5..e91f8bdbed 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_SERVER_NAME import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -45,6 +46,7 @@ class FakeFfiClient( private val userId: String = A_USER_ID.value, private val deviceId: String = A_DEVICE_ID.value, private val homeserver: String = A_HOMESERVER_URL, + private val server: String? = A_SERVER_NAME, private val notificationClient: NotificationClient = FakeFfiNotificationClient(), private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(), private val encryption: Encryption = FakeFfiEncryption(), @@ -59,6 +61,7 @@ class FakeFfiClient( private val accountDataResult: (String) -> String? = { lambdaError() }, private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() }, private val isUserStatusSupportedResult: () -> Boolean = { false }, + private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false }, private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, private val getUrlResult: (String) -> ByteArray = { lambdaError() }, private val closeResult: () -> Unit = {}, @@ -66,6 +69,7 @@ class FakeFfiClient( override fun userId(): String = userId override fun deviceId(): String = deviceId override fun homeserver(): String = homeserver + override fun server(): String? = server override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings override fun encryption(): Encryption = encryption @@ -100,6 +104,8 @@ class FakeFfiClient( override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult() + override suspend fun isProfilesSlidingSyncExtensionSupported(): Boolean = isProfilesSlidingSyncExtensionSupportedResult() + override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle { subscribeToOwnProfileResult(listener) return FakeFfiTaskHandle() diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt new file mode 100644 index 0000000000..416213a0b2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt @@ -0,0 +1,44 @@ +/* + * 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.libraries.matrix.impl.permalink + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class DefaultPermalinkParserTest : RobolectricTest() { + @Test + fun `a matrix-to link to a user id with a slash is parsed as a user link`() { + val result = createParser().parse("https://matrix.to/#/@it/sme:matrix.org") + assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org"))) + } + + @Test + fun `an escaped slash in a user id is parsed as a user link`() { + val result = createParser().parse("https://matrix.to/#/@it%2Fsme:matrix.org") + assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org"))) + } + + @Test + fun `an element web link to a user id with a slash is parsed as a user link`() { + val result = createParser().parse("https://app.element.io/#/user/@it/sme:matrix.org") + assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org"))) + } + + @Test + fun `a user id with a slash that is not a valid user id is not a user link`() { + val result = createParser().parse("https://example.org/foo/@it/sme") + assertThat(result).isInstanceOf(PermalinkData.FallbackLink::class.java) + } + + private fun createParser() = DefaultPermalinkParser( + matrixToConverter = DefaultMatrixToConverter(), + ) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt new file mode 100644 index 0000000000..2018228985 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeClientUrlContentFetcher.kt @@ -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.libraries.matrix.test + +import io.element.android.libraries.matrix.api.ClientUrlContentFetcher +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeClientUrlContentFetcher( + override val server: String = A_SERVER_NAME, + override val homeserverUrl: String = A_HOMESERVER_URL, + private val getUrlResult: (String) -> Result = { lambdaError() }, +) : ClientUrlContentFetcher { + override suspend fun getUrl(url: String): Result { + return getUrlResult(url) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 56d25ed175..6ba42270fe 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -79,6 +79,7 @@ class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")), override val deviceId: DeviceId = A_DEVICE_ID, + override val server: String = A_SERVER_NAME, override val homeserverUrl: String = A_HOMESERVER_URL, override val sessionCoroutineScope: CoroutineScope = TestScope(), private val userDisplayName: String? = A_USER_NAME, @@ -99,7 +100,7 @@ class FakeMatrixClient( override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, - private val resolveRoomAliasResult: (RoomAlias) -> Result> = { + private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = { Result.success( Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) ) @@ -133,6 +134,7 @@ class FakeMatrixClient( private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, override val contentScanner: ContentScanner? = null, + private val isShuttingDownResult: () -> Boolean = { false }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -147,6 +149,7 @@ class FakeMatrixClient( var setUserStatusResult: Result = Result.success(Unit) var clearUserStatusResult: Result = Result.success(Unit) var isUserStatusSupportedResult: Result = Result.success(true) + var isProfilesSlidingSyncExtensionSupportedResult: Result = Result.success(true) private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) override val userProfile: StateFlow = _userProfile @@ -293,6 +296,8 @@ class FakeMatrixClient( override suspend fun isUserStatusSupported(): Result = isUserStatusSupportedResult + override suspend fun isProfilesSlidingSyncExtensionSupported(): Result = isProfilesSlidingSyncExtensionSupportedResult + override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled) override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) @@ -305,6 +310,9 @@ class FakeMatrixClient( return knockRoomLambda(roomIdOrAlias, message, serverNames) } + override val isShuttingDown: Boolean + get() = isShuttingDownResult() + // Mocks fun givenCreateRoomResult(result: Result) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt index 83bde4653a..3ee7fb5f2f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt @@ -11,10 +11,11 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClient import io.element.android.tests.testutils.lambda.lambdaError class FakeTemporaryMatrixClient( + override val server: String = A_SERVER_NAME, + override val homeserverUrl: String = A_HOMESERVER_URL, private val getUrlResult: (String) -> Result = { lambdaError() }, private val closeLambda: () -> Unit = {}, ) : TemporaryMatrixClient { override suspend fun getUrl(url: String): Result = getUrlResult(url) - override fun close() = closeLambda() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt index cdec68bdd1..e906777dcb 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt @@ -13,5 +13,5 @@ import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory class FakeTemporaryMatrixClientFactory( private val createResult: (String) -> Result = { Result.success(FakeTemporaryMatrixClient()) }, ) : TemporaryMatrixClientFactory { - override suspend fun create(homeServerUrl: String): Result = createResult(homeServerUrl) + override suspend fun create(serverName: String): Result = createResult(serverName) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index c41ce0fe3e..c8f49eaf56 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -72,6 +72,7 @@ const val A_SPACE_NAME = "A space name" const val A_REDACTION_REASON = "A redaction reason" +const val A_SERVER_NAME = "matrix.org" const val A_HOMESERVER_URL = "matrix.org" const val A_HOMESERVER_URL_2 = "matrix-client.org" diff --git a/libraries/matrixui/src/main/res/values-ar/translations.xml b/libraries/matrixui/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..5417a5e89f --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ar/translations.xml @@ -0,0 +1,9 @@ + + + "إرسال دعوة" + "هل تريد بدء دردشة مع %1$s؟" + "إرسال دعوة؟" + "ليست لديك حاليًا أي دردشات مع هذا الشخص. أكّد دعوته قبل المتابعة." + "هل تريد بدء دردشة مع جهة الاتصال الجديدة هذه؟" + "دعاك %1$s (%2$s)" + diff --git a/libraries/matrixui/src/main/res/values-es/translations.xml b/libraries/matrixui/src/main/res/values-es/translations.xml index 5d4ac4cf43..26e15d9b2e 100644 --- a/libraries/matrixui/src/main/res/values-es/translations.xml +++ b/libraries/matrixui/src/main/res/values-es/translations.xml @@ -3,5 +3,7 @@ "Enviar invitación" "¿Quieres iniciar un chat con %1$s?" "¿Enviar invitación?" + "De momento no tienes ningún chat con esta persona. Confirma que quieres invitarla antes de continuar." + "¿Iniciar un chat con este nuevo contacto?" "%1$s (%2$s) te invitó" diff --git a/libraries/matrixui/src/main/res/values-fa/translations.xml b/libraries/matrixui/src/main/res/values-fa/translations.xml index 3f5d32f974..9dfece23b2 100644 --- a/libraries/matrixui/src/main/res/values-fa/translations.xml +++ b/libraries/matrixui/src/main/res/values-fa/translations.xml @@ -3,5 +3,7 @@ "فرستادن دعوت" "می‌خواهید گپی را با %1$s بیاغازید؟" "فرستادن دعوت؟" + "شما در حال حاضر هیچ گفت‌وگویی با این شخص ندارید. قبل از ادامه، دعوت از او را تأیید کنید." + "با این مخاطب جدید گفت‌وگو را شروع کنید؟" "%1$s (%2$s) دعوتتان کرد" diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index d35fdb6189..36c5208603 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -63,10 +63,10 @@ class AndroidMediaPreProcessor( /** * Used for calculating `inSampleSize` for bitmaps. * - * *Note*: Ideally, this should result in images of up to (but not included) 1280x1280 being sent. However, images with very different width and height + * *Note*: Ideally, this should result in images of up to (but not included) 2560x2560 being sent. However, images with very different width and height * values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is). */ - private const val IMAGE_SCALE_REF_SIZE = 640 + private const val IMAGE_SCALE_REF_SIZE = 1280 private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP) } @@ -87,8 +87,9 @@ class AndroidMediaPreProcessor( val result = when { resolvedMimeType == MimeTypes.Svg -> processSvgImage(uri, resolvedMimeType) resolvedMimeType.isMimeTypeImage() -> { - val shouldBeCompressed = mediaOptimizationConfig.compressImages && resolvedMimeType !in notCompressibleImageTypes - processImage(uri, resolvedMimeType, shouldBeCompressed) + val imageMimeType = resolveImageMimeType(uri, mimeType).ensureDefaultSubtype() + val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes + processImage(uri, imageMimeType, shouldBeCompressed) } resolvedMimeType.isMimeTypeVideo() -> processVideo(uri, resolvedMimeType, mediaOptimizationConfig.videoCompressionPreset) resolvedMimeType.isMimeTypeAudio() -> processAudio(uri, resolvedMimeType) @@ -157,6 +158,19 @@ class AndroidMediaPreProcessor( } } + /** + * The declared mime type can be broader than the image actually is - a wildcard from a share intent, or a mislabelled file - and the encoder is picked + * from it. Re-encoding a transparent image as JPEG flattens its alpha channel to black, so resolve the real format from the image header first. + */ + private fun resolveImageMimeType(uri: Uri, declaredMimeType: String): String { + if (declaredMimeType == MimeTypes.Png || declaredMimeType == MimeTypes.Jpeg) return declaredMimeType + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + tryOrNull { + contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } + } + return options.outMimeType ?: declaredMimeType + } + private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index b3d5bc805b..090cf11674 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -82,9 +82,9 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { private suspend fun TestScope.process( asset: Asset, mediaOptimizationConfig: MediaOptimizationConfig, - mimeType: String = asset.mimeType, sdkIntVersion: Int = Build.VERSION_CODES.P, deleteOriginal: Boolean = false, + mimeType: String = asset.mimeType, ): MediaUploadInfo { val context = InstrumentationRegistry.getInstrumentation().context val deleteCallback = lambdaRecorder {} @@ -106,6 +106,52 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { return data } + @Test + fun `a png shared with a wildcard mime type is still encoded as a png`() = runTest { + val mediaUploadInfo = process( + asset = assetImagePng, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Png) + } + + @Test + fun `a gif shared with a wildcard mime type is recognised and left uncompressed`() = runTest { + val mediaUploadInfo = process( + asset = assetAnimatedGif, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Gif) + assertThat(info.imageInfo.size).isEqualTo(assetAnimatedGif.size) + } + + @Test + fun `a file that is not an image falls back to the default subtype of the wildcard it was shared with`() = runTest { + val mediaUploadInfo = process( + asset = assetText, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Jpeg) + } + @Test @Ignore("Ignore now that min API for enterprise is 33") fun `test processing png`() = runTest { @@ -223,10 +269,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { assertThat(info.thumbnailFile).isNotNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( - height = 979, - width = 3006, + height = 1958, + width = 6012, mimetype = MimeTypes.Jpeg, - size = 84_845, + size = 682_492, ThumbnailInfo(height = 244, width = 751, mimetype = MimeTypes.Jpeg, size = 7_178), thumbnailSource = null, blurhash = "K07gBzX=j_D4xZjoaSe,s:" @@ -248,10 +294,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { assertThat(info.thumbnailFile).isNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( - height = 979, - width = 3_006, + height = 1_958, + width = 6_012, mimetype = MimeTypes.Jpeg, - size = 84_845, + size = 682_492, thumbnailInfo = null, thumbnailSource = null, blurhash = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt index 4da8433cd1..e23cb790db 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt @@ -266,4 +266,5 @@ class ExoPlayerForPreview( override fun setVideoCodecParameters(codecParameters: CodecParameters) {} override fun addVideoCodecParametersChangeListener(listener: CodecParametersChangeListener, keys: List) {} override fun removeVideoCodecParametersChangeListener(listener: CodecParametersChangeListener) {} + override fun setEnforceAdPlaybackOnTimelineRefresh(enforceAdPlaybackOnTimelineRefresh: Boolean) = throw NotImplementedError() } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index e53153ce19..77222d97ff 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -92,7 +92,7 @@ class MediaViewerNode( is MediaViewerEntryPoint.Params.RoomMedia -> { val eventId = inputs.eventId if (eventId == null) { - // Should not happen + // Local echo, the viewer will not be focused on the item timelineMediaGalleryDataSource } else { // Can we use a specific timeline? diff --git a/libraries/mediaviewer/impl/src/main/res/values-ar/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..0e18b01b6b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,22 @@ + + + "ستتم إزالة هذا الملف من الغرفة ولن يتمكن الأعضاء من الوصول إليه." + "إزالة الملف؟" + "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى." + "ستظهر هنا المستندات والملفات الصوتية والرسائل الصوتية التي يتم رفعها إلى هذه الغرفة." + "لم يتم رفع أي ملفات بعد" + "جارٍ تحميل الملفات…" + "جارٍ تحميل الوسائط…" + "الملفات" + "الوسائط" + "ستظهر هنا الصور ومقاطع الفيديو التي يتم رفعها إلى هذه الغرفة." + "لم يتم رفع أي وسائط بعد" + "الوسائط والملفات" + "التنسيق" + "الاسم" + "لا توجد ملفات أخرى لعرضها" + "لا توجد وسائط أخرى لعرضها" + "معلومات الملف" + "تم الرفع بواسطة" + "تم الرفع في" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-es/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-es/translations.xml index 686dc60658..2a98d41e81 100644 --- a/libraries/mediaviewer/impl/src/main/res/values-es/translations.xml +++ b/libraries/mediaviewer/impl/src/main/res/values-es/translations.xml @@ -6,16 +6,17 @@ "Los documentos, archivos de audio y mensajes de voz subidos a esta sala se mostrarán aquí." "Aún no se ha subido ningún archivo" "Cargando archivos…" - "Cargando medios…" + "Cargando multimedia…" "Archivos" - "Medios" + "Multimedia" "Las imágenes y vídeos subidos a esta sala se mostrarán aquí." "Aún no se ha subido ningún medio" - "Medios y archivos" + "Multimedia y archivos" "Formato de archivo" "Nombre del archivo" "No hay más archivos que mostrar" "No hay más medios que mostrar" + "Información del archivo" "Subido por" "Subido el" diff --git a/libraries/mediaviewer/impl/src/main/res/values-fa/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-fa/translations.xml index 81f8f4db2d..30a9add8f3 100644 --- a/libraries/mediaviewer/impl/src/main/res/values-fa/translations.xml +++ b/libraries/mediaviewer/impl/src/main/res/values-fa/translations.xml @@ -1,5 +1,6 @@ + "این فایل از اتاق حذف خواهد شد و اعضا به آن دسترسی نخواهند داشت." "حذف پرونده؟" "بررسی اتّصال اینترنتیتان و تلاش دوباره." "سندها، پرونده‌ها و پیام‌های صوتی بار گذاشته در این اتاق این‌جا نشان داده خواهند شد." @@ -13,6 +14,9 @@ "رسانه‌ها و پرونده‌ها" "قالب پرونده" "نام پرونده" + "فایل بیشتری برای نمایش وجود ندارد" + "رسانه دیگری برای نمایش وجود ندارد" + "اطلاعات فایل" "بارگذاشته به دست" "بارگذاشته در" diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt index 3ecd091e0d..2a32c6d273 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt @@ -12,6 +12,7 @@ import android.os.Build import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.extensions.normalized import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.SdkMetadata @@ -26,11 +27,11 @@ class DefaultUserAgentProvider( override fun provide(): String = userAgent /** - * Create an user agent with the application version. + * Create a user agent with the application version. * Ex: Element X/1.5.0 (Xiaomi Mi 9T; Android 11; RKQ1.200826.002; Sdk c344b155c) */ private fun buildUserAgent(): String { - val appName = buildMeta.applicationName + val appName = buildMeta.applicationName.normalized() val appVersion = buildMeta.versionName val deviceManufacturer = Build.MANUFACTURER val deviceModel = Build.MODEL diff --git a/libraries/permissions/api/src/main/res/values-ar/translations.xml b/libraries/permissions/api/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..4131b6d27c --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ar/translations.xml @@ -0,0 +1,7 @@ + + + "من أجل جعل التطبيق يستخدم الكاميرا عليك بمنح الإذن لذلك في إعدادات النظام." + "يرجى منح الإذن في إعدادات النظام." + "من أجل جعل التطبيق يستخدم المايكروفون عليك بمنح الإذن لذلك في إعدادت النظام." + "من أجل جعل التطبيق يظهر الإشعارات عليك أن تمنح الإذن في إعدادات النظام." + diff --git a/libraries/permissions/impl/src/main/res/values-ar/translations.xml b/libraries/permissions/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..5d9fb3e1e6 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,5 @@ + + + "يتحقق من قدرة التطبيق على عرض الإشعارات." + "التحقق من الأذونات" + diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index f35fa88442..6285c46389 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -84,6 +84,20 @@ interface AppPreferencesStore { /** The configured log level; defaults to the one the build was compiled with. */ fun getTracingLogLevelFlow(): Flow + /** + * The list of account providers (homeserver URLs) the user has previously authenticated + * against, most recent first. Used to default the account provider and to power autocomplete + * suggestions during sign-in. Stored locally only; never synced across devices. + */ + fun getHomeserverHistoryFlow(): Flow> + + /** + * Add [url] to the front of the account provider history (see [getHomeserverHistoryFlow]). + * The value is normalised (trimmed + lowercased); existing case-insensitive duplicates are + * moved to the front rather than duplicated, and the list is capped in size. No-op for blanks. + */ + suspend fun addHomeserverToHistory(url: String) + /** * @param targets the extra log packs to enable on top of the default targets. */ diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index af35ac3c44..1a14a0ea8f 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.preferences.impl.store +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey @@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate") private val logLevelKey = stringPreferencesKey("logLevel") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") +private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory") private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri") private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion") private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName") @@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion") private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName") +// URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String. +private const val HOMESERVER_HISTORY_DELIMITER = "\n" +private const val MAX_HOMESERVER_HISTORY_SIZE = 20 + @ContributesBinding(AppScope::class) class DefaultAppPreferencesStore( private val buildMeta: BuildMeta, @@ -168,6 +174,20 @@ class DefaultAppPreferencesStore( } } + override fun getHomeserverHistoryFlow(): Flow> { + return store.data.map { prefs -> prefs.readHomeserverHistory() } + } + + override suspend fun addHomeserverToHistory(url: String) { + val normalized = url.trim().lowercase() + if (normalized.isEmpty()) return + store.edit { prefs -> + val updated = (listOf(normalized) + prefs.readHomeserverHistory().filter { it != normalized }) + .take(MAX_HOMESERVER_HISTORY_SIZE) + prefs[homeserverHistoryKey] = updated.joinToString(HOMESERVER_HISTORY_DELIMITER) + } + } + override fun getMessageSoundFlow(): Flow { return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) } } @@ -242,6 +262,13 @@ class DefaultAppPreferencesStore( } } +private fun Preferences.readHomeserverHistory(): List { + return this[homeserverHistoryKey] + ?.split(HOMESERVER_HISTORY_DELIMITER) + ?.filter { it.isNotEmpty() } + ?: emptyList() +} + private fun BuildMeta.defaultLogLevel(): LogLevel { return when (buildType) { BuildType.DEBUG -> LogLevel.TRACE diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt index c52d1648ac..4cf018371a 100644 --- a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt +++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt @@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest { assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25) } + + @Test + fun `homeserver history is empty by default`() = runTest { + val store = createStore() + assertThat(store.getHomeserverHistoryFlow().first()).isEmpty() + } + + @Test + fun `adding homeservers keeps the most recent first`() = runTest { + val store = createStore() + + store.addHomeserverToHistory("https://matrix.org") + store.addHomeserverToHistory("https://example.com") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://example.com", "https://matrix.org") + .inOrder() + } + + @Test + fun `adding an existing homeserver moves it to the front without duplicating`() = runTest { + val store = createStore() + + store.addHomeserverToHistory("https://matrix.org") + store.addHomeserverToHistory("https://example.com") + store.addHomeserverToHistory("https://matrix.org") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://matrix.org", "https://example.com") + .inOrder() + } + + @Test + fun `homeservers are normalised and deduplicated case-insensitively`() = runTest { + val store = createStore() + + store.addHomeserverToHistory(" HTTPS://Matrix.ORG ") + store.addHomeserverToHistory("https://matrix.org") + + assertThat(store.getHomeserverHistoryFlow().first()) + .containsExactly("https://matrix.org") + } + + @Test + fun `blank homeservers are ignored`() = runTest { + val store = createStore() + + store.addHomeserverToHistory(" ") + + assertThat(store.getHomeserverHistoryFlow().first()).isEmpty() + } + + @Test + fun `homeserver history is capped to the most recent entries`() = runTest { + val store = createStore() + + repeat(25) { index -> + store.addHomeserverToHistory("https://server$index.org") + } + + val history = store.getHomeserverHistoryFlow().first() + assertThat(history).hasSize(20) + assertThat(history.first()).isEqualTo("https://server24.org") + assertThat(history.last()).isEqualTo("https://server5.org") + } + + private fun createStore() = DefaultAppPreferencesStore( + buildMeta = buildMeta, + preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + ) } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index 6499e70223..e8d0b0bb8e 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.preferences.api.store.NotificationSound import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet class InMemoryAppPreferencesStore( @@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore( liveLocationMinimumDistanceUpdate: Int = 10, logLevel: LogLevel = LogLevel.INFO, traceLogPacks: Set = emptySet(), + homeserverHistory: List = emptyList(), messageSound: NotificationSound = NotificationSound.SystemDefault, messageSoundChannelVersion: Int = 0, messageSoundDisplayName: String? = null, @@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore( private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate) private val logLevel = MutableStateFlow(logLevel) private val tracingLogPacks = MutableStateFlow(traceLogPacks) + private val homeserverHistory = MutableStateFlow(homeserverHistory) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) private val messageSound = MutableStateFlow(messageSound) @@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore( return tracingLogPacks } + override fun getHomeserverHistoryFlow(): Flow> { + return homeserverHistory + } + + override suspend fun addHomeserverToHistory(url: String) { + val normalized = url.trim().lowercase() + if (normalized.isEmpty()) return + homeserverHistory.update { current -> + (listOf(normalized) + current.filter { it != normalized }).take(10) + } + } + override fun getMessageSoundFlow(): Flow { return messageSound } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt index 975e10e134..3bf065550d 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt @@ -10,4 +10,9 @@ package io.element.android.libraries.push.api.gateway sealed class PushGatewayFailure : Exception() { class PusherRejected : PushGatewayFailure() + + /** + * The gateway refused the notification because too many were sent to it. + */ + class RateLimited : PushGatewayFailure() } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index b9082c50f6..8615485bba 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -1,3 +1,5 @@ +import config.BuildTimeConfig +import extension.buildConfigFieldStr import extension.setupDependencyInjection import extension.testCommonDependencies @@ -17,6 +19,33 @@ plugins { android { namespace = "io.element.android.libraries.push.impl" + buildFeatures { + buildConfig = true + } + + buildTypes { + val defaultPusherAppId = "im.vector.app.android" + getByName("release") { + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_RELEASE ?: defaultPusherAppId, + ) + } + getByName("debug") { + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_DEBUG ?: defaultPusherAppId, + ) + } + register("nightly") { + matchingFallbacks += listOf("release") + buildConfigFieldStr( + name = "PUSHER_APP_ID", + value = BuildTimeConfig.PUSHER_APP_ID_NIGHTLY ?: defaultPusherAppId, + ) + } + } + testOptions { unitTests { isIncludeAndroidResources = true diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt index 35482aa164..7aa519a791 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.push.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.appconfig.PushConfig import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta @@ -74,7 +73,7 @@ class DefaultPusherSubscriber( ): SetHttpPusherData = SetHttpPusherData( pushKey = pushKey, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, // TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()) profileTag = DEFAULT_PUSHER_FILE_TAG + "_", // TODO localeProvider.current().language @@ -104,7 +103,7 @@ class DefaultPusherSubscriber( .unsetHttpPusher( unsetHttpPusherData = UnsetHttpPusherData( pushKey = pushKey, - appId = PushConfig.PUSHER_APP_ID + appId = BuildConfig.PUSHER_APP_ID ) ) .onSuccess { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt index 6315285559..e3a7968909 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -12,6 +12,7 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import retrofit2.HttpException interface PushGatewayNotifyRequest { data class Params( @@ -33,26 +34,38 @@ class DefaultPushGatewayNotifyRequest( val pushGatewayApi = pushGatewayApiFactory.create( params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH) ) - val response = pushGatewayApi.notify( - PushGatewayNotifyBody( - PushGatewayNotification( - eventId = params.eventId.value, - roomId = params.roomId.value, - devices = listOf( - PushGatewayDevice( - params.appId, - params.pushKey, - PusherData(mapOf( - "cs" to "A_FAKE_SECRET", - )) - ) - ), + val response = try { + pushGatewayApi.notify( + PushGatewayNotifyBody( + PushGatewayNotification( + eventId = params.eventId.value, + roomId = params.roomId.value, + devices = listOf( + PushGatewayDevice( + params.appId, + params.pushKey, + PusherData(mapOf( + "cs" to "A_FAKE_SECRET", + )) + ) + ), + ) ) ) - ) + } catch (httpException: HttpException) { + if (httpException.code() in RATE_LIMITED_STATUS_CODES) { + throw PushGatewayFailure.RateLimited() + } + throw httpException + } if (response.rejectedPushKeys.contains(params.pushKey)) { throw PushGatewayFailure.PusherRejected() } } } + +/** + * 429 is the standard answer, ntfy answers 507 when a device is over its message quota. + */ +private val RATE_LIMITED_STATUS_CODES = setOf(429, 507) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt index 4ed8ca8338..6921c050db 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt @@ -10,9 +10,9 @@ package io.element.android.libraries.push.impl.test import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.appconfig.PushConfig import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.impl.BuildConfig import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.pushproviders.api.Config @@ -28,7 +28,7 @@ class DefaultTestPush( pushGatewayNotifyRequest.execute( PushGatewayNotifyRequest.Params( url = config.url, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, pushKey = config.pushKey, eventId = TEST_EVENT_ID, roomId = TEST_ROOM_ID, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt index bc89d031a8..0277d7d2c8 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt @@ -62,6 +62,13 @@ class PushLoopbackTest( ) job.cancel() return + } catch (_: PushGatewayFailure.RateLimited) { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_rate_limit), + status = NotificationTroubleshootTestState.Status.Failure() + ) + job.cancel() + return } catch (e: Exception) { Timber.e(e, "Failed to test push") delegate.updateState( diff --git a/libraries/push/impl/src/main/res/values-ar/translations.xml b/libraries/push/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..7b8e847c72 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,140 @@ + + + "إتصال" + "الاستماع إلى الأحداث" + "إشعارات الصاخبة" + "رنين المكالمات" + "إشعارات صامتة" + + "%1$s: %2$d رسائل" + "%1$s: %2$d رسالة" + "%1$s: %2$d رسالتان" + "%1$s: %2$d رسائل" + "%1$s: %2$d رسالة" + "%1$s: %2$d رسالة" + + + "%d إشعارات" + "%d إشعار" + "%d إشعاران" + "%d إشعارات" + "%d إشعار" + "%d إشعار" + + "تعذّر تسجيل موزّع إشعارات UnifiedPush، لذلك لن تتلقى إشعارات بعد الآن. يرجى التحقق من إعدادات إشعارات التطبيق وحالة موزّع الإشعارات الفورية." + "أتتك رسائل جديدة." + + "لديك %d رسائل جديدة." + "لديك %d رسالة جديدة." + "لديك %d رسالتان جديدتان." + "لديك %d رسائل جديدة." + "لديك %d رسالة جديدة." + "لديك %d رسالة جديدة." + + "📞 مكالمة واردة" + "📹 مكالمة واردة" + "** فشل الإرسال - يرجى فتح الغرفة" + "إنضمام" + "رفض" + + "%d دعوة" + "%d دعوة" + "%d دعوات" + "%d دعوات" + "%d دعوات" + "%d دعوات" + + "دعاك للتحدث" + "دعاك %1$s إلى الدردشة" + "طلبت الإنضمام" + "ذكرك: %1$s" + "رسائل جديدة" + + "%d رسالة جديدة" + "%d رسالة جديدة" + "%d من الرسائل الجديدة" + "%d من الرسائل الجديدة" + "%d من الرسائل الجديدة" + "%d من الرسائل الجديدة" + + "تفاعلت بـ %1$s" + "وضع علامة كمقروء" + "رد سريع " + "دعاك للانضمام للغرفة" + "دعاك %1$s للانضمام إلى الغرفة" + "أنا" + "إشارة أو رد من %1$s" + "دعاك للانضمام إلى المساحة" + "دعاك %1$s للانضمام إلى المساحة" + "إنك ترى الإشعارات! اضغط عليّ!" + "سلسلة في %1$s" + "%1$s:%2$s" + "%1$s:%2$s:%3$s" + + "%d رسالة مُعلَمة لم تُقرأ" + "%d رسالة مُعلَمة لم تُقرأ" + "%d من الرسائل المُعلَمة لم تُقرأ" + "%d من الرسائل المُعلَمة لم تُقرأ" + "%d من الرسائل المُعلَمة لم تُقرأ" + "%d من الرسائل المُعلَمة لم تُقرأ" + + "%1$s و %2$s" + "%1$s في %2$s" + "%1$s في %2$s و %3$s" + + "%d غرفة" + "%d غرفة" + "%d من الغرف" + "%d من الغرف" + "%d من الغرف" + "%d من الغرف" + + "المزامنة في الخلفية" + "خدمات Google" + "لم يتم العثور على خدمات Google صالحة. الإشعارات قد لا تعمل كما يجب." + "التحقق من المستخدمين المحظورين" + "عرض المستخدمين المحظورين" + "لا يوجد مستخدمون محظورون." + + "لقد حظرت %1$d مستخدمين. لن تتلقى إشعارات لهؤلاء المستخدمين." + "لقد حظرت %1$d مستخدم. لن تتلقى إشعارات لهذا المستخدم." + "لقد حظرت %1$d مستخدمين. لن تتلقى إشعارات لهذين المستخدمين." + "لقد حظرت %1$d مستخدمين. لن تتلقى إشعارات لهؤلاء المستخدمين." + "لقد حظرت %1$d مستخدمًا. لن تتلقى إشعارات لهؤلاء المستخدمين." + "لقد حظرت %1$d مستخدم. لن تتلقى إشعارات لهؤلاء المستخدمين." + + "المستخدمون المحظورون" + "يحصل على اسم الموفّر الحالي." + "لم يتم تحديد أي موفّر إشعارات فورية." + "موفّر الإشعارات الفورية الحالي: %1$s والموزّع الحالي: %2$s. لكن لم يتم العثور على الموزّع %3$s. ربما تم إلغاء تثبيت التطبيق؟" + "موفّر الإشعارات الفورية الحالي: %1$s، لكن لم يتم إعداد أي موزّعين." + "موفّر الإشعارات الفورية الحالي: %1$s." + "موفّر الإشعارات الفورية الحالي: %1$s (%2$s)" + "موفّر الإشعارات الفورية الحالي" + "يتحقق من أن التطبيق يدعم موفّر إشعارات فورية واحدًا على الأقل." + "لم يتم العثور على دعم لأي موفّر إشعارات فورية." + + "تم العثور على %1$d من موفّري الإشعارات الفورية: %2$s" + "تم العثور على %1$d موفّر إشعارات فورية: %2$s" + "تم العثور على %1$d من موفّري الإشعارات الفورية: %2$s" + "تم العثور على %1$d من موفّري الإشعارات الفورية: %2$s" + "تم العثور على %1$d من موفّري الإشعارات الفورية: %2$s" + "تم العثور على %1$d من موفّري الإشعارات الفورية: %2$s" + + "تم إنشاء التطبيق بدعم لـ: %1$s" + "دعم موفّر الإشعارات الفورية" + "يتحقق من قدرة التطبيق على عرض إشعار." + "لم يتم النقر على الإشعار." + "تعذّر عرض الإشعار." + "تم النقر على الإشعار!" + "عرض إشعار" + "يرجى النقر على الإشعار لمتابعة الاختبار." + "يتحقق من أن التطبيق يستقبل الإشعارات الفورية." + "خطأ: رفض موفّر الإشعارات الفورية الطلب." + "خطأ: %1$s." + "خطأ، تعذّر اختبار الإشعارات الفورية." + "خطأ، انتهت مهلة انتظار الإشعار الفوري." + "خطأ: بوابة الإشعارات الفورية تفرض قيودًا على معدّل استخدام هذا الجهاز، لذا لن تكون الإشعارات موثوقة. يُرجى إعداد بوابة إشعارات فورية أخرى." + "استغرق اختبار إرسال واستقبال الإشعارات الفورية %1$d مللي ثانية." + "اختبار إرسال واستقبال الإشعارات الفورية" + diff --git a/libraries/push/impl/src/main/res/values-es/translations.xml b/libraries/push/impl/src/main/res/values-es/translations.xml index 412d32bfb6..b66e05be05 100644 --- a/libraries/push/impl/src/main/res/values-es/translations.xml +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -13,7 +13,17 @@ "%d notificación" "%d notificaciones" + "No se pudo registrar el distribuidor de notificaciones de UnifiedPush, así que no recibirás más notificaciones. Comprueba los ajustes de notificaciones de la aplicación y el estado del distribuidor de push." "Tienes nuevos mensajes." + + "Tienes %d mensaje nuevo." + "Tienes %d mensajes nuevos." + + + "Se ha compartido %1$d archivo adjunto" + "Se han compartido %1$d archivos adjuntos" + + "📞 Llamada entrante" "📹 Llamada entrante" "** No se ha podido enviar - por favor, abre la sala" "Unirse" @@ -24,6 +34,7 @@ "Te invitó a chatear" "%1$s te invitó a chatear" + "Comenzó a compartir su ubicación en tiempo real" "Te mencionó: %1$s" "Mensajes nuevos" @@ -37,7 +48,10 @@ "%1$s te invitó a unirte a la sala" "Yo" "%1$s mencionó o respondió" + "Te invitó a unirte al espacio" + "%1$s te invitó a unirte al espacio" "¡Estás viendo la notificación! ¡Haz clic en mí!" + "Hilo en %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -54,10 +68,20 @@ "Sincronización en segundo plano" "Servicios de Google" "No se han encontrado Servicios de Google Play válidos. Es posible que las notificaciones no funcionen correctamente." + "Comprobando usuarios bloqueados" + "Ver usuarios bloqueados" + "No hay usuarios bloqueados." + + "Has bloqueado %1$d usuario. No recibirás notificaciones de este usuario." + "Has bloqueado %1$d usuarios. No recibirás notificaciones de estos usuarios." + "Usuarios bloqueados" "Obtener el nombre del proveedor actual." "No se ha seleccionado ningún proveedor de push." + "Proveedor de push actual: %1$s y distribuidor actual: %2$s. Pero no se encuentra el distribuidor %3$s. ¿Quizás se ha desinstalado la aplicación?" + "Proveedor de push actual: %1$s, pero no se ha configurado ningún distribuidor." "Proveedor de push actual: %1$s." + "Proveedor de push actual: %1$s (%2$s)" "Proveedor de push actual" "Asegurarse de que la aplicación tiene al menos un proveedor de push." "No se encontró ningún proveedor de push." diff --git a/libraries/push/impl/src/main/res/values-fa/translations.xml b/libraries/push/impl/src/main/res/values-fa/translations.xml index 4afd68e17b..f1c70c7f08 100644 --- a/libraries/push/impl/src/main/res/values-fa/translations.xml +++ b/libraries/push/impl/src/main/res/values-fa/translations.xml @@ -13,7 +13,17 @@ "%dاعلان" "%dاعلان‌" + "توزیع‌کننده اعلان UnifiedPush قابل ثبت نیست، بنابراین دیگر اعلانی دریافت نخواهید کرد. لطفاً تنظیمات اعلان‌های برنامه و وضعیت توزیع‌کننده اعلان را بررسی کنید." "پیام‌های جدیدی دارید." + + "شما یک پیام جدید %d دارید." + "شما پیام‌های جدید %d دارید." + + + "پیوست %1$d مشترک" + "پیوست‌های %1$d مشترک" + + "📞 تماس ورودی" "📹 تماس ورودی" "**‌شکست در فرستادن - لطفاً اتاق را بگشایید" "پیوستن" @@ -24,6 +34,7 @@ "به گپ دعوتتان کرد" "%1$s به گپ دعوتتان کرد" + "شروع به اشتراک گذاری موقعیت مکانی زنده خود کردند" "به شما اشاره کرد: %1$s" "پیام جدید" @@ -37,7 +48,10 @@ "%1$s دعوت کرد به اتاق بپیوندید" "خودم" "%1$s اشاره کرد یا پاسخ داد" + "از شما دعوت شد تا به این فضا بپیوندید" + "%1$s از شما دعوت کرد تا به این فضا بپیوندید" "دارید آگاهی را مشاهده می‌کنید! کلیک کنید!" + "رزوه در %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -54,13 +68,28 @@ "همگام سازی پس‌زمینه" "خدمات گوگل" "خدمت پلی گوگل معتبری پیدا نشد. ممکن است آگاهی‌ها به درستی کار نکنند." + "بررسی کاربران مسدود شده" + "مشاهده کاربران مسدود شده" + "هیچ کاربری مسدود نشده است." + + "شما کاربر %1$d را مسدود کرده‌اید. شما اعلان‌هایی برای این کاربر دریافت نخواهید کرد." + "شما کاربران %1$d را مسدود کرده‌اید. برای این کاربران اعلان دریافت نخواهید کرد." + "کاربران مسدود" "گرفتن نام فراهم کنندهٔ کنونی." "هیچ فراهم کنندهٔ ارسالی گزیده نشده." + "ارائه دهنده فعلی پوش: %1$s و توزیع کننده فعلی: %2$s. اما توزیع کننده %3$s پیدا نشد. شاید برنامه حذف نصب شده باشد؟" + "ارائه دهنده فشار فعلی: %1$s، اما هیچ توزیع کننده ای پیکربندی نشده است." "فراهم کنندهٔ ارسال کنونی: %1$s." + "ارائه دهنده خدمات فشار فعلی: %1$s (%2$s)" "فراهم کنندهٔ ارسال کنونی" "ازمینان از پشتیبانی برنامه از لااقل یک فراهم کنندهٔ ارسال." "پشتیبانی از هیچ فراهم کنندهٔ ارسالی پیدا نشد." + + "ارائه دهنده فشار %1$d یافت شد: %2$s" + "ارائه دهندگان فشار %1$d یافت شد: %2$s" + + "این برنامه با پشتیبانی از موارد زیر ساخته شده است: %1$s" "پشتیبانی از فراهم‌کنندهٔ ارسال" "بررسی توانایی برنامه در نمایش آگاهی." "روی آگاهی زده نشد." diff --git a/libraries/push/impl/src/main/res/values-fi/translations.xml b/libraries/push/impl/src/main/res/values-fi/translations.xml index 24c10e8e69..0d8f486663 100644 --- a/libraries/push/impl/src/main/res/values-fi/translations.xml +++ b/libraries/push/impl/src/main/res/values-fi/translations.xml @@ -19,6 +19,10 @@ "Sinulla on %d uusi viesti." "Sinulla on %d uutta viestiä." + + "Jakoi %1$d liitteen" + "Jakoi %1$d liitettä" + "📞 Saapuva puhelu" "📹 Saapuva puhelu" "** Lähetys epäonnistui - avaa huone" @@ -30,6 +34,7 @@ "Kutsui sinut keskustelemaan" "%1$s kutsui sinut keskustelemaan" + "Aloitti reaaliaikaisen sijainnin jakamisen" "Mainitsi sinut: %1$s" "Uusia viestejä" diff --git a/libraries/push/impl/src/main/res/values-fr/translations.xml b/libraries/push/impl/src/main/res/values-fr/translations.xml index 1b0c44d90e..6c129cd3ed 100644 --- a/libraries/push/impl/src/main/res/values-fr/translations.xml +++ b/libraries/push/impl/src/main/res/values-fr/translations.xml @@ -19,6 +19,10 @@ "Vous avez %d nouveau message." "Vous avez %d nouveaux messages." + + "%1$d attachement partagé" + "%1$d attachements partagés" + "📞 Appel entrant" "📹 Appel entrant" "** Échec de l’envoi - veuillez ouvrir le salon" @@ -30,6 +34,7 @@ "Vous a invité(e) à discuter" "%1$s vous a invité à discuter" + "A commencé à partager sa position en direct" "Mentionné(e) : %1$s" "Nouveaux messages" diff --git a/libraries/push/impl/src/main/res/values-zh/translations.xml b/libraries/push/impl/src/main/res/values-zh/translations.xml index eacd1a2040..bbdbabd350 100644 --- a/libraries/push/impl/src/main/res/values-zh/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh/translations.xml @@ -29,6 +29,7 @@ "已邀请你聊天" "%1$s 已邀请你聊天" + "已申请加入" "已开始共享其实时位置" "提到了你:%1$s" "新消息" @@ -92,6 +93,7 @@ "错误:%1$s。" "错误,无法测试推送。" "错误,等待推送超时。" + "错误:推送网关已对此设备限流,因此通知可能不稳定。请更换其它推送网关。" "推送回路耗时 %1$d 毫秒。" "测试推送回路" diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index 4977a9cf3d..bbe6f958b9 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -34,6 +34,7 @@ "Invited you to chat" "%1$s invited you to chat" + "Requested to join" "Started sharing their live location" "Mentioned you: %1$s" "New Messages" @@ -102,6 +103,7 @@ "Error: %1$s." "Error, cannot test push." "Error, timeout waiting for push." + "Error: the push gateway is rate limiting this device, so notifications won\'t be reliable. Please set up a different push gateway." "Push loop back took %1$d ms." "Test Push loop back" diff --git a/libraries/push/impl/src/main/res/values/temporary.xml b/libraries/push/impl/src/main/res/values/temporary.xml deleted file mode 100644 index 494f2ee441..0000000000 --- a/libraries/push/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - "Requested to join" - diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt index 8cb1e2a7f6..76479720be 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.push.impl import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.PushConfig import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData @@ -96,7 +95,7 @@ class DefaultPusherSubscriberTest { value( SetHttpPusherData( pushKey = "aPushKey", - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, url = "aGateway", appDisplayName = "MyApp", deviceDisplayName = "MyDevice", @@ -163,7 +162,7 @@ class DefaultPusherSubscriberTest { value( UnsetHttpPusherData( pushKey = "aPushKey", - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, ), ) ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt index 47dc027b43..97f3bdc540 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt @@ -12,8 +12,11 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.push.api.gateway.PushGatewayFailure import io.element.android.libraries.push.impl.test.DefaultTestPush import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertThrows import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response class DefaultPushGatewayNotifyRequestTest { @Test @@ -64,6 +67,58 @@ class DefaultPushGatewayNotifyRequestTest { assertThat(factory.baseUrlParameter).isEqualTo("aUrl") } + @Test + fun `notify with a rate limiting status should throw expected Exception`() { + listOf(429, 507).forEach { statusCode -> + val factory = FakePushGatewayApiFactory( + notifyResponse = { + throw HttpException(Response.error(statusCode, "".toResponseBody())) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + assertThrows(PushGatewayFailure.RateLimited::class.java) { + runTest { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + } + } + } + } + + @Test + fun `notify with another http error should not be mapped to a rate limit`() { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + throw HttpException(Response.error(500, "".toResponseBody())) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + assertThrows(HttpException::class.java) { + runTest { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + } + } + } + @Test fun `notify with rejected push key should throw expected Exception`() { val factory = FakePushGatewayApiFactory( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt index 9ab0f16b62..3722c09d99 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt @@ -8,7 +8,7 @@ package io.element.android.libraries.push.impl.test -import io.element.android.appconfig.PushConfig +import io.element.android.libraries.push.impl.BuildConfig import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.pushproviders.test.aSessionPushConfig import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -33,7 +33,7 @@ class DefaultTestPushTest { value( PushGatewayNotifyRequest.Params( url = aConfig.url, - appId = PushConfig.PUSHER_APP_ID, + appId = BuildConfig.PUSHER_APP_ID, pushKey = aConfig.pushKey, eventId = DefaultTestPush.TEST_EVENT_ID, roomId = DefaultTestPush.TEST_ROOM_ID, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt index f83fb660ce..447fdd7eb5 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt @@ -59,6 +59,25 @@ class PushLoopbackTestTest { } } + @Test + fun `test PushLoopbackTest RateLimited error`() = runTest { + val sut = createPushLoopbackTest( + pushService = FakePushService( + testPushBlock = { + throw PushGatewayFailure.RateLimited() + } + ), + stringProvider = FakeStringProvider(A_FAILURE_REASON), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + assertThat(lastItem.description).isEqualTo(A_FAILURE_REASON) + } + } + @Test fun `test PushLoopbackTest PusherRejected error with quick fix`() = runTest { val rotateTokenLambda = lambdaRecorder> { Result.success(Unit) } diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md index a2a7ad97ed..d9732af0d0 100644 --- a/libraries/pushproviders/firebase/README.md +++ b/libraries/pushproviders/firebase/README.md @@ -2,6 +2,6 @@ ## Configuration -In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. +In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. -To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `build.gradle.kts` files. +To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `firebase.xml` files. diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index 1a8e73a4da..33e88dd94c 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -8,7 +8,6 @@ @file:Suppress("UnstableApiUsage") -import config.BuildTimeConfig import extension.setupDependencyInjection import extension.testCommonDependencies import org.gradle.kotlin.dsl.withType @@ -28,27 +27,10 @@ android { buildTypes { getByName("release") { consumerProguardFiles("consumer-proguard-rules.pro") - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE, - ) - } - getByName("debug") { - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG, - ) } register("nightly") { consumerProguardFiles("consumer-proguard-rules.pro") matchingFallbacks += listOf("release") - resValue( - type = "string", - name = "google_app_id", - value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY, - ) } } } diff --git a/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml new file mode 100644 index 0000000000..e96f163603 --- /dev/null +++ b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:def0a4e454042e9b00427c + diff --git a/libraries/pushproviders/firebase/src/main/res/values-ar/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..e5229be2f3 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-ar/translations.xml @@ -0,0 +1,11 @@ + + + "يتحقق من توفر Firebase." + "Firebase غير متاح." + "Firebase متاح." + "التحقق من Firebase" + "يتحقق من توفر رمز Firebase المميز." + "رمز Firebase المميز غير معروف." + "رمز Firebase المميز: %1$s." + "التحقق من رمز Firebase المميز" + diff --git a/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml new file mode 100644 index 0000000000..7275ff2ddd --- /dev/null +++ b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:e17435e0beb0303000427c + diff --git a/libraries/pushproviders/firebase/src/release/res/values/firebase.xml b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml new file mode 100644 index 0000000000..3a9a4330d1 --- /dev/null +++ b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:d097de99a4c23d2700427c + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ar/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..355ceef22d --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ar/translations.xml @@ -0,0 +1,14 @@ + + + "يتحقق من توفر موزّعي UnifiedPush." + "لم يتم العثور على أي موزّع UnifiedPush." + + "تم العثور على %1$d من موزّعي UnifiedPush: %2$s." + "تم العثور على %1$d موزّع UnifiedPush: %2$s." + "تم العثور على %1$d من موزّعي UnifiedPush: %2$s." + "تم العثور على %1$d من موزّعي UnifiedPush: %2$s." + "تم العثور على %1$d من موزّعي UnifiedPush: %2$s." + "تم العثور على %1$d من موزّعي UnifiedPush: %2$s." + + "التحقق من UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-fa/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-fa/translations.xml index c80e952967..af2f5f6bad 100644 --- a/libraries/pushproviders/unifiedpush/src/main/res/values-fa/translations.xml +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-fa/translations.xml @@ -2,5 +2,9 @@ "اطمینان از در دسترس بودن توزیعگرهای ارسال یکپارچه." "هیچ توزیعگر ارسال یکپارچه‌ای پیدا نشد." + + "توزیع‌کننده‌ی %1$d پیدا شد: %2$s." + "توزیع‌کنندگان %1$d موارد زیر را یافتند: %2$s." + "بررسی ارسال یکپارچه" diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 66c9dcba03..980443621c 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -245,6 +245,20 @@ fun TextComposer( canSendTextMessage, ) { when { + composerMode.isEditing -> EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, + endButtonClick = { + if (canSendTextMessage) { + onSendMessage() + } + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = canSendTextMessage, + isEditing = true, + ) + }, + ) !canSendTextMessage -> when (voiceMessageState) { VoiceMessageState.Idle -> EndButtonParams( @@ -296,18 +310,6 @@ fun TextComposer( ) } } - composerMode.isEditing -> EndButtonParams( - endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, - endButtonClick = { - onSendMessage() - }, - endButtonContent = @Composable { - SendButtonIcon( - canSendMessage = true, - isEditing = true, - ) - }, - ) else -> EndButtonParams( endButtonContentDescriptionResId = CommonStrings.action_send_message, endButtonClick = { diff --git a/libraries/textcomposer/impl/src/main/res/values-ar/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..594a06ab15 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,33 @@ + + + "إضافة مرفق" + "تبديل قائمة النقاط" + "إلغاء وإغلاق خيارات تنسيق النص" + "تبديل كتلة التعليمات البرمجية" + "أضف تعليقًا توضيحيًا" + "رسالة مشفّرة…" + "راسل…" + "رسالة غير مشفّرة…" + "إنشاء رابط" + "تعديل الرابط" + "%1$s، الحالة: %2$s" + "تطبيق التنسيق الغامق" + "تطبيق التنسيق المائل" + "معطّل" + "غير مفعّل" + "مفعّل" + "تطبيق التنسيق الذي يتوسّطه خط" + "تطبيق التنسيق الذي تحته خط" + "تبديل وضع ملء الشاشة" + "ترك مسافة بادئة" + "تطبيق تنسيق الكود المضمن" + "تعيين الرابط" + "تبديل القائمة المرقمة" + "فتح خيارات كتابة الرسالة" + "تبديل الاقتباس" + "حذف الرابط" + "إزالة المسافة البادئة" + "الرابط" + "قد لا تكون التعليقات التوضيحية مرئية للأشخاص الذين يستخدمون تطبيقات أقدم." + "اضغط مطولًا للتسجيل" + diff --git a/libraries/textcomposer/impl/src/main/res/values-es/translations.xml b/libraries/textcomposer/impl/src/main/res/values-es/translations.xml index 68b8b7b02c..16016658e5 100644 --- a/libraries/textcomposer/impl/src/main/res/values-es/translations.xml +++ b/libraries/textcomposer/impl/src/main/res/values-es/translations.xml @@ -10,9 +10,12 @@ "Mensaje no cifrado…" "Crear un enlace" "Editar enlace" + "%1$s, estado: %2$s" "Aplicar formato negrita" "Aplicar formato cursiva" "deshabilitado" + "desactivado" + "activado" "Aplicar formato tachado" "Aplicar formato de subrayado" "Pantalla completa" diff --git a/libraries/textcomposer/impl/src/main/res/values-fa/translations.xml b/libraries/textcomposer/impl/src/main/res/values-fa/translations.xml index fd55fdf9d9..c3fcffc642 100644 --- a/libraries/textcomposer/impl/src/main/res/values-fa/translations.xml +++ b/libraries/textcomposer/impl/src/main/res/values-fa/translations.xml @@ -28,5 +28,6 @@ "برداشتن پیوند" "تونرفتگی" "پیوند" + "ممکن است زیرنویس‌ها برای افرادی که از برنامه‌های قدیمی‌تر استفاده می‌کنند قابل مشاهده نباشند." "نگه داشتن برای ضبط" diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt new file mode 100644 index 0000000000..c979e8c909 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/TextComposerEndButtonTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * Copyright 2026 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. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.textcomposer + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.wysiwyg.display.TextDisplay +import org.junit.Test + +class TextComposerEndButtonTest : RobolectricTest() { + @Test + fun `editing a message with text shows the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "Some text", composerMode = aMessageComposerModeEdit()) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + } + + @Test + fun `emptying the composer while editing keeps the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = aMessageComposerModeEdit()) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist() + } + + @Test + fun `emptying the composer while editing a caption keeps the send edited message button`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = aMessageComposerModeEditCaption(content = "An existing caption")) + onNodeWithContentDescription(getString(CommonStrings.action_send_edited_message)).assertIsDisplayed() + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertDoesNotExist() + } + + @Test + fun `an empty composer outside of edition still offers to record a voice message`() = runAndroidComposeUiTest { + setTextComposer(text = "", composerMode = MessageComposerMode.Normal) + onNodeWithContentDescription(getString(CommonStrings.a11y_voice_message_record)).assertIsDisplayed() + } + + private fun AndroidComposeUiTest.getString(resId: Int): String { + return activity!!.getString(resId) + } + + private fun AndroidComposeUiTest.setTextComposer( + text: String, + composerMode: MessageComposerMode, + ) { + setContent { + TextComposer( + state = aTextEditorStateMarkdown(initialText = text, initialFocus = true), + voiceMessageState = VoiceMessageState.Idle, + composerMode = composerMode, + onRequestFocus = {}, + onSendMessage = {}, + onResetComposerMode = {}, + onAddAttachment = {}, + onDismissTextFormatting = {}, + onVoiceRecorderEvent = {}, + onVoicePlayerEvent = {}, + onSendVoiceMessage = {}, + onDeleteVoiceMessage = {}, + onError = {}, + onTyping = {}, + onReceiveSuggestion = {}, + onSelectRichContent = null, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + ) + } + } +} diff --git a/libraries/troubleshoot/impl/src/main/res/values-ar/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..85331aa86a --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-ar/translations.xml @@ -0,0 +1,12 @@ + + + "سجل الإشعارات الفورية" + "تشغيل الاختبارات" + "تشغيل الاختبارات مرة أخرى" + "فشلت بعض الاختبارات. يرجى التحقق من التفاصيل." + "شغّل الاختبارات لاكتشاف أي مشكلة في إعداداتك قد تجعل الإشعارات لا تعمل كما هو متوقع." + "محاولة الإصلاح" + "اجتازت جميع الاختبارات بنجاح." + "استكشاف أخطاء الإشعارات وإصلاحها" + "تتطلب بعض الاختبارات انتباهك. يرجى التحقق من التفاصيل." + diff --git a/libraries/ui-strings/src/main/res/values-ar/translations.xml b/libraries/ui-strings/src/main/res/values-ar/translations.xml new file mode 100644 index 0000000000..d8862e6dc7 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ar/translations.xml @@ -0,0 +1,611 @@ + + + "أضف صورة رمزية" + "أضف تفاعل: %1$s" + "العنوان" + "الصورة الرمزية" + "مسح حقل البحث" + "تصغير حقل نص الرسالة" + "تقليل عدد الأصوات المسموح بها لكل شخص" + "زيادة عدد الأصوات المسموح بها لكل شخص" + "احذف" + + "تم إدخال %1$d أرقام" + "تم إدخال %1$d رقم" + "تم إدخال %1$d رقمين" + "تم إدخال %1$d أرقام" + "تم إدخال %1$d رقمًا" + "تم إدخال %1$d رقم" + + "المدة: %1$s" + "تعديل الصورة الرمزية" + "العنوان الكامل سيكون %1$s" + "تفاصيل التشفير" + "تكبير حقل نص الرسالة" + "أخفِ كلمة السر" + "معلومات" + "انضم للمكالمة" + "الانتقال للأسفل" + "الانتقال إلى الرسائل غير المقروءة" + "انقل الخريطة إلى موقعي" + "الإشارات فقط" + "مكتوم" + "إشارات جديدة" + "رسائل جديدة" + "مكالمة جارية" + "صورة رمزية لمستخدم آخر" + "الصفحة %1$d" + "إيقاف مؤقت" + "الرسالة الصوتية، المدة: %1$s ، الموضع الحالي: %2$s" + "معاينة الصورة" + "حقل الرمز السري (PIN) " + "موقع محدد" + "تشغيل" + "سرعة التشغيل" + "استفتاء" + "استفتاء منتهٍ" + "الموضع: %1$s" + "رمز QR" + "تفاعل مع %1$s" + "تفاعل مع الرموز التعبيرية الأخرى" + "قرأ %1$s و%2$s الرسالة السابقة" + + "قرأ %1$s و%2$d آخرون الرسالة السابقة" + "قرأ %1$s و%2$d آخر الرسالة السابقة" + "قرأ %1$s و%2$d آخران الرسالة السابقة" + "قرأ %1$s و%2$d آخرون الرسالة السابقة" + "قرأ %1$s و%2$d آخر الرسالة السابقة" + "قرأ %1$s و%2$d آخر الرسالة السابقة" + + "قرأ %1$s الرسالة السابقة" + "إظهار كل إيصالات القراءة" + "أزِل التفاعل: %1$s" + "أزِل التفاعل بـ %1$s" + "الصورة الرمزية للغرفة" + "إرسال ملفات" + "موقع المرسل" + "أرسلها %1$s في %2$s" + "إجراء مقيد زمنياً، لديك دقيقة واحدة للتحقق " + "الإعدادات، إجراء مطلوب" + "إظهار كلمة السر" + "بدء مكالمة" + "بدء مكالمة فيديو" + "بدء مكالمة صوتية" + "سلسلة في %1$s" + "سلاسل في %1$s" + "غرفة مستبدلة" + "الصورة الرمزية للمستخدم" + "قائمة المستخدم" + "معاينة الفيديو" + "عرض الصورة الرمزية" + "عرض التفاصيل" + "رسالة صوتية، المدة: %1$s" + "تسجيل رسالة صوتية." + "إيقاف التسجيل" + "الصورة الرمزية الخاصة بك" + "قبول" + "أضف وصف" + "إضافة غرف موجودة" + "أضف إلى الجدول الزمني" + "رجوع" + "مكالمة" + "الغاء" + "ليس الآن" + "اختيار ملف" + "اختيار صورة" + "مسح" + "إغلاق" + "أتمم التحقق" + "تأكيد" + "تأكيد كلمة المرور" + "المتابعة" + "نسخ" + "نسخ التعليق التوضيحي" + "نسخ الرابط" + "نسخ رابط الرسالة" + "انسخ النص" + "إنشاء" + "إنشاء غرفة" + "إنشاء مساحة" + "تعطيل" + "تعطيل الحساب" + "رفض" + "رفض وحظر" + "حذف" + "حذف الحساب" + "حذف الملف" + "حذف الاستطلاع" + "إلغاء تحديد الكل" + "تعطيل" + "تجاهل" + "إغلاق" + "تم" + "تنزيل" + "تعديل" + "تعديل التعليق التوضيحي" + "تعديل الاستفتاء" + "تمكين" + "إنهاء الاستفتاء" + "إدخال رمز PIN" + "استكشاف المساحات العامة" + "إنهاء" + "أنسيت كلمة السر؟" + "إعادة توجيه" + "ارجع" + "الانتقال إلى الأدوار والصلاحيات" + "اذهب إلى الإعدادات" + "تجاهل" + "دعوة " + "دعوة أشخاص" + "قم بدعوة الأصدقاء إلى %1$s" + "دعوة أشخاص إلى %1$s" + "الدعوات" + "إنضمام" + "تعلم المزيد" + "مغادرة" + "مغادرة المحادثة" + "مغادرة الغرفة" + "مغادرة المساحة" + "تحميل المزيد" + "إدارة الحساب" + "إدارة الحساب والأجهزة" + "إدارة الأجهزة" + "إدارة الغرف" + "إرسال رسالة" + "تصغير" + "التالي" + "لا" + "ليس الآن" + "حسناً" + "فتح قائمة السياق" + "الإعدادات" + "فتح باستخدام" + "تثبيت" + "رد سريع " + "عبارة" + "تفاعَل" + "رفض" + "ازالة" + "إزالة التعليق التوضيحي" + "إزالة الرسالة" + "رد" + "ردّ في ثريد" + "إبلاغ" + "الإبلاغ عن مشكلة" + "الابلاغ عن المحتوى" + "الإبلاغ عن المحادثة" + "الإبلاغ عن الغرفة" + "إعادة تعيين" + "إعادة تعيين الهوية" + "اعادة المحاولة" + "أعد محاولة فك التشفير" + "حفظ" + "بحث" + "تحديد الكل" + "ارسال" + "إرسال الرسالة المعدّلة" + "أرسل رسالة" + "إرسال رسالة صوتية" + "مشاركة" + "مشاركة رابط" + "مشاركة الموقع المباشر" + "إظهار" + "تسجيل الدخول مرة أخرى" + "إزالة هذا الجهاز" + "إزالة هذا الجهاز على أي حال" + "تخطي" + "ابدأ" + "ابدأ المحادثة" + "البدء من جديد" + "ابدأ التحقق" + "انقر لتحميل الخريطة" + "إيقاف" + "التقاط صورة" + "اضغط لعرض الخيارات" + "ترجمة" + "حاول مرة أخرى" + "إلغاء التثبيت" + "مكالمة فيديو" + "عرض" + "عرض في المخطط الزمني" + "عرض المصدر" + "مكالمة صوتية" + "نعم" + "نعم، حاول مرة أخرى" + "يدعم خادمك الآن بروتوكولًا جديدًا أسرع. سجّل الخروج ثم سجّل الدخول مرة أخرى للترقية الآن. سيساعدك القيام بذلك الآن على تجنب تسجيل خروج قسري عند إزالة البروتوكول القديم لاحقًا." + "ترقية متاحة" + "حول التطبيق" + "سياسة الاستخدام المقبول" + "إضافة حساب" + "إضافة حساب آخر" + "جارٍ إضافة تعليق توضيحي" + "إعدادات متقدمة" + "صورة" + "التحليلات" + "قمتَ بمغادرة المجموعة" + "تم تسجيل خروجك من الجلسة" + "المظهر" + "الصوت" + "تجريبي" + "المستخدمون المحظورون" + "فقاعات" + "تم رفض المكالمة" + "مكالمة جارية" + "بدأت المكالمة" + "رفضت مكالمة" + "النسخ الاحتياطي للدردشة" + "تم النسخ إلى الحافظة" + "حقوق الملكية" + "يتم إنشاء الغرفة…" + "جارٍ إنشاء المساحة…" + "تم إلغاء الطلب" + "غادرت الغرفة" + "غادرت المساحة" + "تم رفض الدعوة" + "خطأ في فك التشفير" + "الوصف" + "خيارات المطوِّرين" + "معرّف الجهاز" + "الدردشة المباشرة" + "عدم إظهار هذا مرة أخرى" + "فشل التنزيل" + "جارٍ التنزيل" + "(مُعدّلة)" + "تعديل" + "جارٍ تعديل التعليق التوضيحي" + "* %1$s %2$s" + "ملف فارغ" + "التشفير" + "التشفير مفعّل" + "ينتهي عند %1$s" + "أدخل رمز PIN الخاص بك" + "خطأ" + "حدث خطأ، قد لا تتلقى إشعارات للرسائل الجديدة. يرجى استكشاف أخطاء الإشعارات وإصلاحها من الإعدادات. + +السبب: %1$s." + "الجميع" + "فشل" + "مفضلة" + "مضافة إلى المفضلة" + "جارٍ مزامنة الإشعارات…" + "ملف" + "تمت إزالة الملف" + "تم حفظ الملف" + "تم حفظ الملف في التنزيلات" + "إعادة توجيه الرسالة" + "المستخدمة بكثرة" + "المعرض" + "GIF" + "مكالمة جماعية جارية" + "صورة" + "ردٌّ على %1$s" + "تثبيت ملف الـAPK" + "لم يتم العثور على الـMatrix ID هذا، ولذا فالدعوة قد لا تكون وصلت." + "تتم مغادرة المجموعة" + "جارٍ مغادرة المساحة" + "تم نسخ السطر إلى الحافظة" + "تم نسخ الرابط إلى الحافظة" + "ربط جهاز جديد" + "الموقع المباشر" + "انتهت مشاركة الموقع المباشر" + "جارٍ التحميل…" + "جارٍ تحميل المزيد…" + + "%d آخرون" + "%d آخر" + "%d آخران" + "%d آخرون" + "%d آخر" + "%d آخر" + + + "%1$d أعضاء" + "%1$d عضو" + "%1$d عضو" + "%1$d أعضاء" + "%1$d عضو" + "%1$d عضو" + + "رسالة" + "إجراءات الرسالة" + "فشل إرسال الرسالة" + "مظهر الرسائل" + "حُذِفت الرسالة" + "حديث" + "كتم" + "الاسم" + "%1$s (%2$s)" + "لا نتائج" + "لا يوجد اسم للغرفة" + "لا يوجد اسم للمساحة" + "غير مشفّر" + "غير متصل" + "تراخيص المصادر المفتوحة" + "أو" + "خيارات أخرى" + "كلمة السر" + "الأشخاص" + "الرابط الثابت" + "الإذن" + "مثبّتة" + "يرجى التحقق من اتصالك بالإنترنت" + "يرجى الانتظار…" + "أمتأكد من رغبتك بإنهاء الاستفتاء؟" + "استفتاء: %1$s" + "استطلاع" + "مجموع الأصوات: %1$s" + "ستظهر النتائج في حين انتهاء الاستفتاء" + + "%d صوت" + "%d صوت" + "%d صوت" + "%d أصوات" + "%d صوت" + "%d صوت" + + "جارٍ التحضير…" + "سياسة الخصوصية" + "خاصة" + "غرفة خاصة" + "مساحة خاصة" + "عامة" + "غرفة عامة" + "مساحة عامة" + "التفاعل" + "ردود الفعل" + "السبب" + "مفتاح الاسترداد" + "يتم التحديث…" + "جارٍ الإزالة…" + + "%1$d ردود" + "%1$d رد" + "%1$d ردّان" + "%1$d ردود" + "%1$d ردًا" + "%1$d رد" + + "الرد على %1$s" + "الإبلاغ عن مشكلة" + "أُرسِل الإبلاغ" + "محرر النص المنسق" + "الدور" + "غرفة" + "اسم الغرفة" + "مثال: اسم مشروعك" + + "%1$d غرف" + "%1$d غرفة" + "%1$d غرفتان" + "%1$d غرف" + "%1$d غرفة" + "%1$d غرفة" + + "تم حفظ التغييرات" + "جارٍ الحفظ" + "قفل الشاشة" + "اِبحث عن شخص ما" + "نتائج البحث" + "الأمان" + "شوهد بواسطة" + "اختر حسابًا" + + "تم تحديد %1$d" + "تم تحديد %1$d" + "تم تحديد %1$d" + "تم تحديد %1$d" + "تم تحديد %1$d" + "تم تحديد %1$d" + + "إرسال إلى" + "جارٍ الإرسال…" + "فشل الإرسال" + "تم الإرسال" + ". " + "السيرفر غير مدعوم" + "الخادم غير متاح" + "عنوان URL للسيرفر" + "الإعدادات" + "مشاركة المساحة" + "يمكن للأعضاء الجدد رؤية السجل" + "تمت مشاركة الموقع المباشر" + "الموقع المشارَك" + "مساحة مشتركة" + "جارٍ إزالة الجهاز" + "حدث خطأ ما" + "واجهنا مشكلة. يرجى المحاولة مرة أخرى." + "مساحة" + "أعضاء المساحة" + "ما موضوع هذه المساحة؟" + + "%1$d مساحات" + "%1$d مساحة" + "%1$d مساحتان" + "%1$d مساحات" + "%1$d مساحة" + "%1$d مساحة" + + "يتم بدء المحادثة…" + "مُلصَق" + "تمت العملية بنجاح" + "مقترح" + "اقتراحات" + "تتم المزامنة" + "نص" + "إشعارات الجهات الخارجية" + "ثريد" + "السلاسل" + "الموضوع" + "ما موضوع هذه الغرفة؟" + "غير قادر على فك التشفير" + "أُرسلت من جهاز غير آمن" + "ليس لديك صلاحية الوصول إلى هذه الرسالة" + "تمت إعادة تعيين الهوية الرقمية الموثّقة للمرسل" + "لم يتم التمكن من إرسال الدعوة إلى عضو أو أكثر." + "غير قادر على إرسال الدعوة (الدعوات)" + "إلغاء القفل" + "إلغاء الكتم" + "مكالمة غير مدعومة" + "حدث غير مدعوم" + "بدأ %1$s مكالمة" + "اسم المستخدم" + "أُلغيَ التحقق" + "اكتمل التحقق" + "فشل التحقق" + "تم التحقق" + "التحقّق من الجهاز" + "تحقّق من الهوية" + "تحقّق من المستخدم" + "فيديو" + "جودة عالية" + "أفضل جودة، لكن بحجم ملف أكبر" + "جودة منخفضة" + "أسرع سرعة رفع وأصغر حجم ملف" + "جودة قياسية" + "توازن بين الجودة وسرعة الرفع" + "رسالة صوتية" + "جارٍ الانتظار…" + "في انتظار هذه الرسالة" + "في انتظار الموقع المباشر…" + "يمكن لأي شخص رؤية السجل" + "أنت" + "التنزيل غير متاح." + "لم يتم العثور على الملف" + "جاري المسح…" + "تم تعطيل المعاينة والتنزيل." + "الملف غير آمن" + "شارك %1$s (%2$s) هذه الرسالة لأنك لم تكن في الغرفة عند إرسالها." + "شارك %1$s هذه الرسالة لأنك لم تكن في الغرفة عند إرسالها." + "تم إعداد هذه الغرفة بحيث يمكن للأعضاء الجدد قراءة السجل. %1$s" + "تمت إعادة تعيين الهوية الرقمية لـ %1$s. %2$s" + "تمت إعادة تعيين الهوية الرقمية الخاصة بـ %1$s %2$s. %3$s" + "(%1$s)" + "تمت إعادة تعيين الهوية الرقمية الخاصة بـ %1$s." + "تمت إعادة تعيين الهوية الرقمية الخاصة بـ %1$s %2$s. %3$s" + "إلغاء التحقق" + "السماح بالوصول" + "ينقلك الرابط %1$s إلى موقع آخر %2$s + +هل أنت متأكد من رغبتك في المتابعة؟" + "تحقّق جيدًا من هذا الرابط" + "اختر الجودة الافتراضية للفيديوهات التي ترفعها." + "جودة رفع الفيديو" + "الحد الأقصى المسموح به لحجم الملف هو: %1$s" + "حجم الملف كبير جدًا ولا يمكن رفعه" + "تم الإبلاغ عن الغرفة" + "تم الإبلاغ عن الغرفة ومغادرتها" + "التأكيد" + "خطأ" + "تمت العملية بنجاح" + "تحذير" + "لديك تغييرات غير محفوظة." + "لم تُحفظ تغييراتك. هل أنت متأكد من أنك تريد الرجوع؟" + "حفظ التغييرات؟" + "الحد الأقصى المسموح به لحجم الملف هو: %1$s" + "اختر جودة الفيديو الذي تريد رفعه." + "اختر جودة رفع الفيديو" + "لقد سجّلت الدخول بالفعل على هذا الجهاز باسم %1$s." + "يجب ترقية خادمك المنزلي لدعم Matrix Authentication Service وإنشاء الحسابات." + "فشِل إنشاء رابطٍ دائم (permalink)" + "%1$s لم يستطع تحميل الخريطة. يرجى المحاولة مجدداً لاحقاً." + "فشِل تحميل الرسائل" + "%1$s لم يستطع الوصول إلى موقعك. يرجى المحاولة مجدداً لاحقاً." + "تعذّر رفع رسالتك الصوتية." + "لم تعد الغرفة موجودة أو لم تعد الدعوة صالحة." + "يرجى تفعيل GPS للوصول إلى الميزات المستندة إلى الموقع." + "لم يتم العثور على الرسالة" + "لا يملك %1$s الإذن بالوصول إلى موقعك. يمكنك تفعيل وصوله في الإعدادات." + "لا يملك %1$s الإذن بالوصول إلى موقعك. فعّل وصوله تحت." + "ليس لدى %1$s إذن للوصول إلى الميكروفون. اسمح بالوصول لتسجيل رسالة صوتية." + "قد يكون ذلك بسبب مشكلات في الشبكة أو الخادم." + "عنوان الغرفة هذا موجود بالفعل. يرجى محاولة تعديل حقل عنوان الغرفة أو تغيير اسم الغرفة" + "بعض الأحرف غير مسموح بها. يُسمح فقط بالحروف والأرقام والرموز التالية: ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "لم تُرسَل بعض الرسائل" + "عذراً، قد حدث خطأ" + "🔐️ انضم إليّ في %1$s" + "مرحباً، راسلني في %1$s: %2$s" + "مشاركة الموقع المباشر" + "جارٍ مشاركة الموقع" + "%1$s - الموقع المباشر" + "%1$s Android" + "رجّ الهاتف للإبلاغ عن خطأ" + "لقطة شاشة" + "%1$s: %2$s" + "الخيارات" + "إزالة %1$s" + "الإعدادات" + "الأصوات المسموح بها لكل شخص" + "لا يشارك أحد موقعه" + "مشاركة الموقع المباشر" + + "%1$d أشخاص" + "%1$d شخص" + "%1$d شخصان" + "%1$d أشخاص" + "%1$d شخصًا" + "%1$d شخص" + + "على الخريطة" + "تعذّر اختيار الوسائط، يرجى المحاولة مجدداً." + "اضغط على رسالة واختر “%1$s” لإضافتها هنا." + "ثبّت الرسائل المهمة لتسهيل العثور عليها" + + "%1$d رسائل مثبّتة" + "%1$d رسالة مثبّتة" + "%1$d رسالتان مثبّتتان" + "%1$d رسائل مثبّتة" + "%1$d رسالة مثبّتة" + "%1$d رسالة مثبّتة" + + "الرسائل المثبّتة" + "أنت على وشك الانتقال إلى حسابك على %1$s لإعادة تعيين هويتك الرقمية. بعد ذلك ستعود إلى التطبيق." + "لا يمكنك التأكيد؟ انتقل إلى حسابك لإعادة تعيين هويتك الرقمية." + "إلغاء التحقق والإرسال" + "يمكنك إلغاء التحقق الذي أجريته وإرسال هذه الرسالة على أي حال، أو الإلغاء في الوقت الحالي والمحاولة مرة أخرى لاحقًا بعد إعادة التحقق من %1$s." + "لم تُرسل رسالتك لأن الهوية الرقمية الموثّقة لـ %1$s أُعيد تعيينها" + "إرسال الرسالة على أي حال" + "يستخدم %1$s جهازًا واحدًا أو أكثر لم يتم التحقق منه. يمكنك إرسال الرسالة على أي حال، أو الإلغاء في الوقت الحالي والمحاولة مرة أخرى لاحقًا بعد أن يتحقق %2$s من جميع أجهزته." + "لم تُرسل رسالتك لأن %1$s لم يتحقق من جميع الأجهزة" + "لم يتم التحقق من جهاز واحد أو أكثر من أجهزتك. يمكنك إرسال الرسالة على أي حال، أو الإلغاء في الوقت الحالي والمحاولة مرة أخرى لاحقًا بعد أن تتحقق من جميع أجهزتك." + "لم تُرسل رسالتك لأنك لم تتحقق من جهاز واحد أو أكثر من أجهزتك" + "تعديل المسؤولين أو المالكين" + "تعذّرت معالجة الوسائط من أجل رفعها، يرجى المحاولة مجدداً." + "تعذّر استرداد معلومات المستخدم" + "رسالة في %1$s" + "توسيع" + "طي" + "مشاركة الموقع المباشر" + "أنت في هذه الغرفة بالفعل!" + "%1$s من %2$s" + "%1$s رسائل مثبّتة" + "جارٍ تحميل الرسالة…" + "عرض الكل" + "الدردشة" + "مشاركة موقع" + "شارك موقعي" + "فتح في Apple Maps" + "فتح في Google Maps" + "فتح في OpenStreetMap" + "مشاركة الموقع الجغرافي المُحدّد" + "خيارات المشاركة" + "المساحات التي أنشأتها أو انضممت إليها." + "%1$s • %2$s" + "أنشئ مساحات لتنظيم الغرف" + "مساحة %1$s" + "المساحات" + "تمت المشاركة %1$s" + "على الخريطة" + "لم تُرسل الرسالة لأن الهوية الرقمية الموثّقة لـ %1$s أُعيد تعيينها." + "لم تُرسل الرسالة لأن %1$s لم يتحقق من جميع الأجهزة." + "لم تُرسل الرسالة لأنك لم تتحقق من جهاز واحد أو أكثر من أجهزتك." + "الموقع" + "الإصدار: %1$s (%2$s)" + "ar" + "الرسائل السابقة غير متاحة على هذا الجهاز" + "يجب التحقق من هذا الجهاز للوصول إلى الرسائل السابقة" + "ليس لديك صلاحية الوصول إلى هذه الرسالة" + "تعذّر فك تشفير الرسالة" + "تم حظر هذه الرسالة إما لأنك لم تتحقق من جهازك أو لأن المرسل يحتاج إلى التحقق من هويتك الرقمية." + "جهازك قديم جدًا؛ يلزم جهاز يعمل بنظام Android 8 أو أحدث." + "عرض الخرائط غير مدعوم" + diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index c068e23d6c..223b8aa457 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -1,51 +1,101 @@ + "Añadir avatar" "Añadir reacción: %1$s" + "Dirección" "Avatar" + "Borrar consulta" + "Minimizar el campo de texto del mensaje" + "Disminuir el número de votos permitidos por persona" + "Aumentar el número de votos permitidos por persona" + "Emoji personalizado" "Borrar" "%1$d dígito introducido" "%1$d dígitos introducidos" + "Duración: %1$s" + "Editar avatar" + "La dirección completa será %1$s" + "Detalles de cifrado" + "Expandir campo de texto del mensaje" + "Galería, archivo adjunto %1$d" + "Galería, escaneo fallido %1$d" + "Galería, imagen %1$d" + + "Galería, %1$d elemento multimedia más" + "Galería, %1$d elementos multimedia más" + + "Galería, escaneando %1$d" + "Galería, vídeo %1$d" "Ocultar contraseña" + "Info" "Unirse a la llamada" "Ir al final" + "Ir a los mensajes no leídos" + "Ir al primer mensaje no leído" + "Desplazar el mapa a mi ubicación" "Sólo menciones" "Silenciado" + "Menciones nuevas" + "Mensajes nuevos" + "Llamada en curso" + "Avatar del otro usuario" "Página %1$d" "Pausar" "Mensaje de voz, duración: %1$s, posición actual: %2$s" + "Vista previa de la foto" "Campo PIN" + "Ubicación fijada" "Reproducir" + "Velocidad de reproducción" "Encuesta" "Encuesta finalizada" + "Posición: %1$s" + "Código QR" "Reacciona con %1$s" "Reacciona con otros emojis" - "Leído por %1$s y %2$s" + "Mensaje anterior leído por %1$s y %2$s" - "Leído por %1$s y %2$d otro" - "Leído por %1$s y %2$d otros" + "Mensaje anterior leído por %1$s y %2$d más" + "Mensaje anterior leído por %1$s y %2$d más" - "Leído por %1$s" + "Mensaje anterior leído por %1$s" + "mostrar todas las confirmaciones de lectura" "Elimina la reacción con %1$s" "Retirar la reacción con %1$s" "Avatar de la sala" "Enviar archivos" + "Ubicación del remitente" + "Enviado por %1$s a las %2$s" + "Acción de tiempo limitado requerida, tienes un minuto para hacer la verificación" + "Ajustes, acción requerida" "Mostrar contraseña" "Iniciar llamada" + "Iniciar una videollamada" + "Iniciar una llamada de voz" + "Estado" + "Hilo en %1$s" + "Hilos en %1$s" + "Sala clausurada" + "Avatar del usuario" "Menú de usuario" + "Vista previa del vídeo" "Ver avatar" "Mostrar detalles" "Mensaje de voz, duración: %1$s" "Grabar mensaje de voz" "Detener grabación" + "Tu avatar" "Aceptar" "Agregar leyenda" + "Añadir salas existentes" "Añadir a la cronología" "Atrás" "Llamar" "Cancelar" "Cancelar por ahora" + "Seleccionar archivo" "Elegir foto" "Borrar" "Cerrar" @@ -60,24 +110,34 @@ "Copiar texto" "Crear" "Crear una sala" + "Crear espacio" "Desactivar" "Desactivar cuenta" "Rechazar" "Rechazar y bloquear" + "Eliminar" + "Eliminar cuenta" + "Eliminar archivo" "Eliminar encuesta" + "Deseleccionar todo" "Desactivar" "Descartar" "Descartar" "Hecho" + "Descargar" "Editar" "Editar leyenda" "Editar encuesta" "Activar" "Finalizar encuesta" "Introducir PIN" + "Explorar espacios públicos" + "Finalizar" "¿Olvidaste tu contraseña?" "Reenviar" "Volver atrás" + "Ir a roles y permisos" + "Ir a ajustes" "Ignorar" "Invitar" "Invitar personas" @@ -89,14 +149,20 @@ "Salir" "Salir de la conversación" "Salir de la sala" + "Salir del espacio" "Cargar más" "Gestionar cuenta" + "Gestionar cuenta y dispositivos" "Administrar dispositivos" + "Gestionar salas" + "Marcar como leído" "Enviar mensaje" + "Minimizar" "Siguiente" "No" "Ahora no" "OK" + "Abrir menú contextual" "Ajustes" "Abrir con" "Fijar" @@ -108,7 +174,7 @@ "Quitar leyenda" "Eliminar mensaje" "Responder" - "Responder en el hilo" + "Responder en hilo" "Denunciar" "Informar de un error" "Denunciar contenido" @@ -120,51 +186,69 @@ "Reintentar descifrado" "Guardar" "Buscar" + "Seleccionar todo" "Enviar" + "Enviar mensaje editado" "Enviar mensaje" + "Enviar mensaje de voz" "Compartir" "Compartir enlace" + "Compartir ubicación en directo" "Mostrar" "Inicia sesión de nuevo" - "Cerrar sesión" - "Cerrar sesión de todos modos" + "Eliminar este dispositivo" + "Eliminar este dispositivo de todos modos" "Saltar" "Comenzar" "Iniciar chat" "Empezar de nuevo" "Iniciar la verificación" "Pulsa para cargar el mapa" + "Parar" "Hacer foto" "Toca para ver opciones" + "Traducir" "Intentar de nuevo" "Desprender" + "Videollamada" "Ver" "Ver en la cronología" "Ver fuente" + "Llamada de voz" "Sí" "Sí, intentar de nuevo" "Tu servidor es ahora compatible con un protocolo nuevo y más rápido. Cierra la sesión y vuelve a iniciarla para actualizar ahora. Haciéndolo ahora, evitarás tener que cerrar la sesión cuando se elimine el protocolo antiguo." "Actualización disponible" "Acerca de" "Política de uso aceptable" + "Añadir una cuenta" + "Añadir cuenta" "Añadiendo leyenda" "Ajustes avanzados" "una imagen" "Estadísticas" "Saliste de la sala" + "Se te ha desconectado de la sesión" "Apariencia" "Sonido" + "Beta" "Usuarios bloqueados" "Burbujas" + "Llamada rechazada" + "Llamada en curso" "Llamada iniciada" + "Rechazaste una llamada" "Copia de seguridad del chat" "Copiado al portapapeles" "Derechos de autor" "Creando sala…" + "Creando espacio…" "Solicitud cancelada" "Saliste de la sala" + "Saliste del espacio" "Invitación rechazada" "Error de descifrado" + "Descripción" "Opciones de desarrollador" "ID de dispositivo" "Chat directo" @@ -178,6 +262,7 @@ "Archivo vacío" "Cifrado" "Cifrado activado" + "Termina a las %1$s" "Introduce tu PIN" "Error" "Se ha producido un error, es posible que no recibas notificaciones de mensajes nuevos. Soluciona los problemas con las notificaciones desde los ajustes. @@ -187,20 +272,35 @@ Motivo: %1$s." "Falló" "Favorito" "Marcado como favorito" + "Sincronizando notificaciones…" "Archivo" "Archivo eliminado" "Archivo guardado" "Archivo guardado en Descargas" "Reenviar mensaje" "Usado frecuentemente" + "Galería" + + "%1$d archivo adjunto…" + "%1$d archivos adjuntos…" + + + "%1$d elemento multimedia…" + "%1$d elementos multimedia…" + "GIF" + "Llamada grupal en curso" "Imagen" "En respuesta a %1$s" "Instalar APK" "No se encontró este ID de Matrix, por lo que es posible que no se reciba la invitación." "Saliendo de la sala" + "Saliendo del espacio" "Línea copiada al portapapeles" "Enlace copiado al portapapeles" + "Vincular un dispositivo nuevo" + "Location en directo" + "La ubicación en tiempo real ha terminado" "Cargando…" "Cargando más…" @@ -213,17 +313,22 @@ Motivo: %1$s." "Mensaje" "Acciones del mensaje" + "El mensaje no se pudo enviar" "Diseño de los mensajes" "Mensaje eliminado" "Moderno" "Silenciar" + "Nombre" "%1$s (%2$s)" "No hay resultados" "Sala sin nombre" + "Espacio sin nombre" "No cifrado" "Sin conexión" + "En una llamada" "Licencias de código abierto" "o" + "Otras opciones" "Contraseña" "Personas" "Enlace permanente" @@ -233,27 +338,43 @@ Motivo: %1$s." "Espera, por favor…" "¿Estás seguro de que quieres finalizar esta encuesta?" "Encuesta: %1$s" + "Encuesta" "Total de votos: %1$s" "Los resultados se mostrarán una vez finalizada la encuesta" "%d voto" "%d votos" + "Preparando…" "Política de privacidad" + "Privado" "Sala privada" + "Espacio privado" + "Público" "Sala pública" + "Espacio público" "Reacción" "Reacciones" "Motivo" "Clave de recuperación" "Recargando…" + "Eliminando…" + + "%1$d respuesta" + "%1$d respuestas" + "Respondiendo a %1$s" "Informar de un problema" "Informe enviado" "Editor de texto enriquecido" + "Rol" "Sala" "Nombre de la sala" "p. ej., el nombre de tu proyecto" + + "%1$d sala" + "%1$d salas" + "Cambios guardados" "Guardando" "Bloqueo de pantalla" @@ -261,37 +382,58 @@ Motivo: %1$s." "Buscar resultados" "Seguridad" "Visto por" + "Elige una cuenta" + + "%1$d seleccionado" + "%1$d seleccionados" + "Enviar a" "Enviando…" "Fallo al enviar" "Enviado" + ". " "Servidor no compatible" + "Servidor inaccesible" "Dirección del servidor" "Ajustes" + "Compartir espacio" + "Los miembros nuevos ven el historial" + "Ubicación compartida en directo" "Ubicación compartida" - "Cerrando sesión" + "Espacio compartido" + "Eliminando dispositivo" "Algo salió mal" "Hemos encontrado un problema. Inténtalo de nuevo." + "Espacio" + "Miembros del espacio" + "¿De qué trata este espacio?" + + "%1$d espacio" + "%1$d espacios" + "Iniciando chat…" "Sticker" "Terminado" + "Sugerido" "Sugerencias" "Sincronizando" "Texto" "Avisos de terceros" "Hilo" + "Hilos" "Tema" "¿De qué trata esta sala?" "No se puede descifrar" "Enviado desde un dispositivo no seguro" "No tienes acceso a este mensaje" - "Se restableció la identidad verificada del remitente" + "Se restableció la identidad digital verificada del remitente" "Las invitaciones no se pudieron enviar a uno o más usuarios." "No se pudo enviar la(s) invitación(es)" "Desbloquear" "Dejar de silenciar" "Llamada no compatible" "Evento no compatible" + "%1$s inició una llamada" "Usuario" "Verificación cancelada" "Verificación completada" @@ -301,20 +443,41 @@ Motivo: %1$s." "Verificar identidad" "Verificar usuario" "Vídeo" + "Calidad alta" + "La mejor calidad, pero mayor tamaño de archivo" + "Calidad baja" + "Velocidad de subida más rápida y tamaño de archivo más pequeño" + "Calidad estándar" + "Equilibrio entre calidad y velocidad de subida" "Mensaje de voz" "Esperando…" "Esperando este mensaje" + "Esperando la ubicación en directo…" + "Cualquiera puede ver el historial" "Tú" - "Se restableció la identidad de %1$s. %2$s" - "Se restableció la identidad de %1$s %2$s. %3$s" + "Descarga no disponible." + "Algo salió mal" + "Escaneando…" + "Se ha desactivado la vista previa y la descarga." + "El archivo no es seguro" + "%1$s (%2$s) compartió este mensaje ya que no estabas en la sala cuando se envió." + "%1$s compartió este mensaje ya que no estabas en la sala cuando se envió." + "Esta sala se ha configurado para que los miembros nuevos puedan leer el historial. %1$s" + "Se restableció la identidad digital de %1$s. %2$s" + "Se restableció la identidad digital de %1$s %2$s. %3$s" "(%1$s)" - "Se restableció la identidad de %1$s." - "Se restableció la identidad de %1$s %2$s. %3$s" + "Se restableció la identidad digital de %1$s." + "Se restableció la identidad digital de %1$s %2$s. %3$s" "Retirar la verificación" + "Permitir acceso" "El enlace %1$s te está llevando a otro sitio %2$s ¿Estás seguro de que quieres continuar?" "Revisa este enlace" + "Selecciona la calidad por defecto de los vídeos que subas." + "Calidad de subida de vídeos" + "El tamaño máximo de archivo permitido es: %1$s" + "El archivo es demasiado grande para ser subido" "Sala denunciada" "Denunciaste y saliste de la sala" "Confirmar" @@ -324,6 +487,10 @@ Motivo: %1$s." "Tienes cambios sin guardar." "Tus cambios no se han guardado. ¿Estás seguro de que quieres volver atrás?" "¿Guardar cambios?" + "El tamaño máximo de archivo permitido es: %1$s" + "Selecciona la calidad del vídeo que quieres subir." + "Selecciona la calidad de subida del vídeo" + "Ya has iniciado sesión en este dispositivo como %1$s." "Tu servidor base debe actualizarse para admitir Matrix Authentication Service y la creación de cuentas." "No se pudo crear el enlace permanente" "%1$s no pudo cargar el mapa. Por favor vuelve a intentarlo más tarde." @@ -331,6 +498,7 @@ Motivo: %1$s." "%1$s no ha podido acceder a tu ubicación. Por favor vuelve a intentarlo más tarde." "No se pudo cargar tu mensaje de voz." "La sala ya no existe o la invitación ya no es válida." + "Activa el GPS para acceder a las funciones basadas en la ubicación." "Mensaje no encontrado" "%1$s no tiene permiso para acceder a tu ubicación. Puedes habilitar el acceso en Ajustes." "%1$s no tiene permiso para acceder a tu ubicación. Habilita el acceso a continuación." @@ -342,10 +510,34 @@ Motivo: %1$s." "Lo siento, se ha producido un error" "🔐️ Únete a mí en %1$s" "Hola, puedes hablar conmigo en %1$s: %2$s" + "Compartir la ubicación en directo" + "Se está compartiendo actualmente la ubicación" + "%1$s Ubicación en tiempo real" "%1$s Android" "Agitar con fuerza para informar de un error" + "Captura de pantalla" + "Iniciar sesión" + "Crear cuenta" + "Si tu cuenta te la proporciona tu empresa o una comunidad, cambia el proveedor de la cuenta (ejemplo: nombredeempresa.com)." + "Crea una cuenta gratuita en matrix.org. Si deseas crear una cuenta con otro proveedor, modifícala según corresponda (por ejemplo: communityname.org)." + "Proveedor de cuentas" + "ejemplo.com" + "Introduce tu proveedor de cuenta" + "Elegir proveedor de cuentas" + "%1$s: %2$s" "Opciones" + "Eliminar %1$s" "Ajustes" + "Votos permitidos por persona" + "Nadie está compartiendo su ubicación" + "Compartiendo ubicación en tiempo real" + + "%1$d persona" + "%1$d personas" + + "En el mapa" + "Tu servidor base está en tu red local. Para conectarse, %1$s necesita permiso para acceder a los dispositivos de esta red." + "Permite el acceso a la red local" "Error al seleccionar archivos multimedia, por favor inténtalo de nuevo." "Presiona sobre un mensaje y selecciona «%1$s» para incluirlo aquí." "Fija los mensajes importantes para que se puedan descubrir fácilmente" @@ -354,21 +546,23 @@ Motivo: %1$s." "%1$d mensajes fijados" "Mensajes fijados" - "Estás a punto de ir a tu cuenta de %1$s para restablecer tu identidad. Posteriormente volverás a la aplicación." - "¿No puedes confirmar? Ve a tu cuenta para restablecer tu identidad." + "Estás a punto de ir a tu cuenta de %1$s para restablecer tu identidad digital. Posteriormente volverás a la aplicación." + "¿No puedes confirmarla? Ve a tu cuenta para restablecer tu identidad digital." "Retirar la verificación y enviar" "Puedes retirar tu verificación y enviar este mensaje de todos modos, o puedes cancelarlo por ahora e intentarlo de nuevo más tarde después de volver a verificar a %1$s." - "Tu mensaje no se envió porque la identidad verificada de %1$s fue restablecida" + "Tu mensaje no se envió porque la identidad digital verificada de %1$s fue restablecida" "Enviar mensaje de todos modos" "%1$s utiliza uno o más dispositivos no verificados. Puedes enviar el mensaje de todos modos, o puedes cancelarlo por ahora y volver a intentarlo más tarde, una vez %2$s haya verificado todos sus dispositivos." "Tu mensaje no se envió porque %1$s no ha verificado todos los dispositivos" "Uno o más de tus dispositivos no están verificados. Puedes enviar el mensaje de todos modos, o puedes cancelarlo por ahora e intentarlo de nuevo más tarde, una vez hayas verificado todos tus dispositivos." "Tu mensaje no se envió porque no has verificado uno o más de tus dispositivos" + "Editar administradores o propietarios" "Error al procesar el contenido multimedia, por favor inténtalo de nuevo." "No se pudieron recuperar los detalles del usuario" "Mensaje en %1$s" "Expandir" "Reducir" + "Compartiendo ubicación en tiempo real" "¡Ya estás viendo esta sala!" "%1$s de %2$s" "%1$s mensajes fijados" @@ -380,8 +574,20 @@ Motivo: %1$s." "Abrir en Apple Maps" "Abrir en Google Maps" "Abrir en OpenStreetMap" - "Compartir esta ubicación" - "Mensaje no enviado porque la identidad verificada de %1$s fue restablecida." + "Compartir ubicación seleccionada" + "Cómo compartir" + "Espacios que has creado o a los que te has unido." + "%1$s • %2$s" + "Crea espacios para organizar salas" + "Espacio %1$s" + "Espacios" + "Compartida %1$s" + "En el mapa" + + "Se unió %1$d" + "Se unieron %1$d" + + "Mensaje no enviado porque la identidad digital verificada de %1$s fue restablecida." "Mensaje no enviado porque %1$s no ha verificado todos los dispositivos." "Mensaje no enviado porque no has verificado uno o más de tus dispositivos." "Ubicación" @@ -391,5 +597,7 @@ Motivo: %1$s." "Debes verificar este dispositivo para acceder a los mensajes históricos" "No tienes acceso a este mensaje" "No se ha podido descifrar el mensaje" - "Este mensaje fue bloqueado bien sea porque no verificaste tu dispositivo o porque el remitente necesita verificar tu identidad." + "Este mensaje fue bloqueado bien sea porque no verificaste tu dispositivo o porque el remitente necesita verificar tu identidad digital." + "Tu dispositivo es demasiado antiguo; necesitas un dispositivo con Android 8 o posterior." + "No se admite el renderizado de mapas" diff --git a/libraries/ui-strings/src/main/res/values-fa/translations.xml b/libraries/ui-strings/src/main/res/values-fa/translations.xml index 03d1172040..49a790c2ea 100644 --- a/libraries/ui-strings/src/main/res/values-fa/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fa/translations.xml @@ -1,18 +1,30 @@ + "اضافه کردن آواتار" "افزودن واکنش: %1$s" + "آدرس" "چهرک" + "پاک کردن فیلد جستجو" + "کوچک کردن فیلد متن پیام" + "کاهش آرای مجاز برای هر نفر" + "افزایش تعداد آرای مجاز برای هر نفر" + "ایموجی‌های سفارشی" "حذف" "%1$d رقم وارد شده" "%1$d رقم وارد شده" + "مدت زمان: %1$s" "ویرایش چهرک" "نشانی کامل %1$s خواهد بود" "جزییات رمزنگاری" + "فیلد متن پیام را گسترش دهید" "نهفتن گذرواژه" + "اطلاعات" "پیوستن به تماس" "به پایین بروید" + "پرش به خوانده نشده" + "پرش به اولین پیام خوانده نشده" "جابه‌جایی نقشه به مکانم" "فقط اشاره‌ها" "خموش" @@ -23,10 +35,15 @@ "صفحهٔ %1$d" "مکث" "پیام صوتی. طول %1$s. موقعیت کنونی: %2$s" + "پیش‌نمایش عکس" "زمینهٔ پین" + "مکان پین شده" "پخش" + "سرعت پخش" "نظرسنجی" "نظرسنجی پایان یافته" + "موقعیت: %1$s" + "کد QR" "واکنش با %1$s" "واکنش با شکلک‌های دیگر" "پیام پیشین به دست %1$s و %2$s خوانده شد" @@ -35,15 +52,26 @@ "پیام پیشین به دست %1$s و %2$d دیگر خوانده شد" "پیام قبلی به دست %1$s خوانده شد" + "نمایش همه رسیدهای خوانده شده" "برداشتن واکنش با %1$s" "برداشتن واکنش با %1$s" "چهرک اتاق" "ارسال پرونده‌ها" - "نیازمند کنش محدود به زمان" + "مکان فرستنده" + "ارسال شده توسط %1$s در %2$s" + "اقدام مورد نیاز با محدودیت زمانی، شما یک دقیقه برای تأیید فرصت دارید" + "تنظیمات، اقدام مورد نیاز" "نمایش گذرواژه" "آغاز یک تماس" + "شروع تماس ویدیویی" + "شروع تماس صوتی" + "وضعیت" + "رزوه در %1$s" + "موضوعات در %1$s" + "اتاق سنگ قبر" "چهرک کاربر" "فهرست کاربر" + "پیش‌نمایش ویدیو" "دیدن چهرک" "دیدن جزییات" "پیام صوتی. طول %1$s" @@ -52,11 +80,13 @@ "چهرکتان" "پذیرش" "افزودن عنوان" + "افزودن اتاق‌های موجود" "افزودن به خط زمانی" "بازگشت" "تماس" "لغو" "لغو برای امنون" + "انتخاب فایل" "گزینش عکس" "پاک سازی" "بستن" @@ -71,26 +101,34 @@ "رونوشت از متن" "ایجاد" "ایجاد اتاق" + "ایجاد فضا" "غیرفعّال" "غیرفعّال‌سازی حساب" "رد" "رد و انسداد" + "پاک‌کردن" + "حذف حساب کاربری" + "حذف فایل" "حذف نظرسنجی" "ناگزینش همه" "از کار انداختن" "دور ریختن" "دورانداختن" "انجام شد" + "دانلود" "ویرایش" "ویرایش عنوان" "ویرایش نظرسنجی" "به کار انداختن" "پایان نظرسنجی" "ورود پین" + "کاوش در فضاهای عمومی" "پایان" "گذرواژه را فراموش کردید؟" "پیشروی" "پس‌روی" + "به نقش‌ها و مجوزها بروید" + "به تنظیمات بروید" "نادیده‌گرفتن" "دعوت" "دعوت افراد" @@ -105,8 +143,12 @@ "ترک فضا" "بار کردن بیش‌تر" "مدیریت حساب" + "مدیریت حساب و دستگاه‌ها" "مدیریت افزاره‌ها" + "مدیریت اتاق‌ها" + "علامت‌گذاری به عنوان خوانده شده" "پیام" + "کوچک کردن" "بعدی" "نه" "الآن نه" @@ -120,8 +162,8 @@ "واکنش" "رد کردن" "حذف" - "برداشتن عنوان" - "برداشتن پیام‌ها" + "حذف عنوان" + "حذف پیام" "پاسخ" "پاسخ در رشته" "گزارش" @@ -142,25 +184,31 @@ "فرستادن پیام صوتی" "هم‌رسانی" "هم‌رسانی پیوند" + "اشتراک گذاری موقعیت مکانی زنده" "نمایش" "ورود دوباره" "برداشتن این افزاره" "به هر حال این دستگاه را حذف کنید" - "پرش" + "رد شدن" "آغاز" "آغاز گپ" "آغاز از نو" "آغاز تأیید" "زدن برای بار کردن نقشه" + "توقف" "عکس گرفتن" "زدن برای گزینه‌ها" + "ترجمه" "تلاش دوباره" "سنجاق نکردن" + "تماس تصویری" "نما" "دیدن در خط زمانی" "دیدن منبع" + "تماس صوتی" "بله" "بله. تلاش دوباره" + "سرور شما اکنون از یک پروتکل جدید و سریع‌تر پشتیبانی می‌کند. برای ارتقا، از سیستم خارج شوید و دوباره وارد شوید. انجام این کار در حال حاضر به شما کمک می‌کند تا از خروج اجباری هنگام حذف پروتکل قدیمی در آینده جلوگیری کنید." "ارتقا موجود است" "درباره" "سیاست استفادهٔ پذیرفتنی" @@ -168,18 +216,24 @@ "افزودن حسابی دیگر" "افزودن عنوان" "تنظیمات پیش‌رفته" + "یک تصویر" "تجزیه و تحلیل" "اتاق را ترک کردید" + "شما از جلسه خارج شدید" "ظاهر" "صدا" "آزمایشی" "کاربران مسدود" "حباب‌ها" + "تماس رد شد" + "تماس در جریان است" "تماس آغاز شد" + "شما یک تماس را رد کردید" "پشتیبان گپ" "در تخته‌گیره رونوشت شد" "حق رونوشت" "ایجاد کردن اتاق…" + "ایجاد فضای…" "درخواست لغو شد" "اتاق را ترک کرد" "فضا را ترک کرد" @@ -199,19 +253,34 @@ "پروندهٔ خالی" "رمزنگاری" "رمزنگاری به کار افتاده" + "در %1$s پایان می‌یابد" "پینتان را وارد کنید" "خطا" + "خطایی رخ داده است، ممکن است اعلان‌های مربوط به پیام‌های جدید را دریافت نکنید. لطفاً اعلان‌ها را از تنظیمات عیب‌یابی کنید. + +دلیل: %1$s." "هرکسی" "شکست خورد" "برگزیده" "برگزیده" + "همگام‌سازی اعلان‌ها…" "پرونده" "پرونده حذف شد" "پرونده ذخیره شد" "پرونده در بارگیری‌ها ذخیره شد" "هدایت پیام" "پراستفاده" + "گالری" + + "پیوست %1$d…" + "پیوست‌های %1$d…" + + + "مورد رسانه‌ای %1$d…" + "موارد رسانه‌ای %1$d…" + "جیف" + "تماس گروهی در جریان است" "تصویر" "در پاسخ به %1$s" "نصب APK" @@ -220,6 +289,9 @@ "ترک کردن فضا" "خط در تخته‌گیره رونوشت شد" "پیوند در تخته‌گیره رونوشت شد" + "اتصال دستگاه جدید" + "مکان زنده" + "مکان زنده به پایان رسید" "بار کردن…" "بار کردن بیش‌تر…" @@ -232,18 +304,22 @@ "پیام" "کنش‌های پیام" + "پیام ارسال نشد" "جینش پیام" "پیام برداشته شد" "نوین" "بی‌صدا" + "نام" "%1$s ‏(%2$s)" "بدون نتیجه" "بدون نام اتاق" "بدون نام فضا" "رمزنگاری نشده" "برون‌خط" + "در یک تماس" "پروانه‌های نرم‌افزاری آزاد" "یا" + "گزینه‌های دیگر" "گذرواژه" "افراد" "پایاپیوند" @@ -253,6 +329,7 @@ "لطفا صبر کنید…" "مطئنید که می‌خواهید این نظرسنجی را پایان دهید؟" "نظرسنجی: %1$s" + "نظرسنجی" "مجموع آرا: %1$s" "نتیجه‌ها پس از پایان نظرسنجی نشان داده خواهند شد" @@ -261,8 +338,10 @@ "آماده سازی…" "سیاست محرمانگی" + "خصوصی" "اتاق خصوصی" "فضای خصوصی" + "عمومی" "اتاق عمومی" "فضای عمومی" "واکنش" @@ -270,13 +349,23 @@ "دلیل" "کلید بازیابی" "تازه سازی…" + "در حال حذف…" + + "پاسخ %1$d" + "پاسخ‌های %1$d" + "پاسخ دادن به %1$s" "گزارش مشکل" "گزارش ثبت شد" "ویرایشگر متن غنی" + "نقش" "اتاق" "نام اتاق" "برای نمونه نام پروژه‌تان" + + "اتاق %1$d" + "اتاق‌های %1$d" + "تغییرات ذخیره شده" "ذخیره کردن" "قفل صفحه" @@ -285,6 +374,10 @@ "امنیت" "دیده شده به دست" "گزینش حساب" + + "%1$d انتخاب شد" + "%1$d انتخاب شد" + "فرستادن به" "فرستادن…" "فرستادن شکست خورد" @@ -295,29 +388,43 @@ "نشانی کارساز" "تنظیمات" "هم‌رسانی فضا" + "اعضای جدید تاریخچه را می‌بینند" + "مکان زنده به اشتراک‌گذاری شده" "مکان هم‌رسانده" "فضای اشتراکی" "خارج شدن" "چیزی اشتباه پیش رفت" + "با مشکلی مواجه شدیم. لطفا دوباره امتحان کنید." "فاصله" + "اعضای فضا" + "این فضا مربوط به چیست؟" + + "فضای %1$d" + "فضاهای %1$d" + "آغازیدن گپ…" "عکس برگردان" "موفّقیت" + "پیشنهادی" "پیشنهادها" "هم‌گام ساختن" "متن" "تذکّرهای سوم‌شخص" "رشته" + "موضوعات" "موضوع" "این اتاق دربارهٔ چیست؟" "ناتوان در رمزگشایی" + "ارسال شده از یک دستگاه ناامن" "به این پیام دسترسی ندارید" + "هویت دیجیتال تأیید شده فرستنده بازنشانی شد" "دعوت‌ها نتوانستند به کاربرانی برسند." "ناتوان در فرستادن دعوت(ها)" "قفل‌گشایی" "باصدا" "تماس پشتیبانی نشده" "رویداد پشتیبانی نشده" + "%1$s یک تماس را آغاز کرد" "نام کاربری" "تأیید لغو شد" "تأیید کامل شد" @@ -328,17 +435,40 @@ "تأیید کاربر" "ویدیو" "کیفیت بالا" + "بهترین کیفیت اما حجم فایل بیشتر" "کیفیت پایین" + "سریع‌ترین سرعت آپلود و کمترین حجم فایل" "کیفیت استاندارد" + "تعادل بین کیفیت و سرعت آپلود" "پیام صوتی" "در انتظار…" "در انتظار این پیام" + "در انتظار موقعیت مکانی زنده…" + "هر کسی می‌تواند تاریخچه را ببیند" "شما" - "هویت %1$s بازنشانی شد. %2$s" + "دانلود در دسترس نیست." + "چیزی اشتباه پیش رفت" + "اسکن…" + "پیش‌نمایش و دانلود غیرفعال شده‌اند." + "فایل امن نیست" + "%1$s (%2$s) این پیام را به اشتراک گذاشته است زیرا شما هنگام ارسال آن در اتاق حضور نداشتید." + "%1$s این پیام را به اشتراک گذاشت زیرا شما هنگام ارسال آن در اتاق نبودید." + "این اتاق طوری پیکربندی شده است که اعضای جدید بتوانند تاریخچه را بخوانند. %1$s" + "هویت دیجیتال %1$s بازنشانی شد. %2$s" + "هویت دیجیتال %2$s مربوط به %1$s بازنشانی شد. %3$s" "(%1$s)" + "هویت دیجیتال %1$s بازنشانی شد." + "هویت دیجیتال %2$s مربوط به %1$s بازنشانی شد. %3$s" "انصراف از تأیید" + "اجازه دسترسی" + "لینک %1$s شما را به سایت دیگری هدایت می‌کند %2$s + +آیا مطمئنید که می‌خواهید ادامه دهید؟" "این پیوند را دوباره بررسی کنید" + "کیفیت پیش‌فرض ویدیوهایی که آپلود می‌کنید را انتخاب کنید." "کیفیت بارگذاری ویدیو" + "حداکثر اندازه فایل مجاز: %1$s" + "حجم فایل برای آپلود بسیار زیاد است" "اتاق گزارش شد" "گزارش و از اتاق خارج شد" "تایید" @@ -348,35 +478,75 @@ "تغییراتی ذخیره نشده دارید." "تغییراتتان ذخیره نشده‌اند. مطمئنید که می‌خواهید برگردید؟" "ذخیرهٔ تغییرات؟" + "حداکثر اندازه فایل مجاز: %1$s" + "کیفیت ویدیویی که می‌خواهید آپلود کنید را انتخاب کنید." + "کیفیت آپلود ویدیو را انتخاب کنید" + "شما قبلاً با نام کاربری %1$s در این دستگاه وارد سیستم شده‌اید." + "سرور خانگی شما باید ارتقا یابد تا از سرویس احراز هویت ماتریکس و ایجاد حساب کاربری پشتیبانی کند." "شکست در ایجاد پایاپیوند" "%1$s نتوانست نقشه را بارگیری کند. لطفا بعدا دوباره امتحان کنید." "شکست در بار کردن پیام‌ها" "‏%1$s نمی تواند به مکانتان دسنترسی داشته باشد. لطفا بعدا دوباره امتحان کنید." "شکست در بارگذاری پیام صوتیتان." + "اتاق دیگر وجود ندارد یا دعوتنامه دیگر معتبر نیست." + "برای دسترسی به ویژگی‌های مبتنی بر موقعیت مکانی، لطفاً GPS خود را فعال کنید." "پیام پیدا نشد" "‏%1$s اجازهٔ دسترسی به مکانتان را ندارد. می توانید دسترسی را در تنظیمات به کار بیندازید." "%1$s اجازهٔ دسترسی به مکانتان را ندارد. دسترسی را در زیر به کار بیندازید." "%1$s اجازه دسترسی به میکروفون شما را ندارد. دسترسی را برای ضبط پیام صوتی فعال کنید." + "این مشکل ممکن است به دلیل مشکلات شبکه یا سرور باشد." + "این آدرس اتاق از قبل وجود دارد. لطفاً فیلد آدرس اتاق را ویرایش کنید یا نام اتاق را تغییر دهید." + "بعضی از کاراکترها مجاز نیستند. فقط حروف، ارقام و نمادهای زیر پشتیبانی می‌شوند! $ &amp; ' ( ) * + / ; = ? @ [ ] - . _" "برخی پیام‌ها ارسال نشده‌اند" "متأسفیم ، خطایی رخ داد" "🔐️ پییوستن به من روی %1$s" "درود. با من روی %1$s صحبت کن: %2$s" + "اشتراک‌گذاری موقعیت مکانی زنده" + "اشتراک‌گذاری موقعیت مکانی در حال انجام است" + "موقعیت مکانی زنده %1$s" "%1$s اندروید" "Rageshake برای گزارش اشکال" "نماگرفت" "%1$s: %2$s" "گزینه‌ها" + "حذف %1$s" "تنظیمات" + "تعداد آرای مجاز برای هر نفر" + "هیچ‌کس موقعیت مکانی خود را به اشتراک نمی‌گذارد" + "اشتراک‌گذاری موقعیت مکانی زنده" + + "شخص %1$d" + "افراد %1$d" + + "روی نقشه" + "سرور خانگی شما در شبکه محلی شما قرار دارد. برای اتصال، %1$s برای دسترسی به دستگاه‌های این شبکه به مجوز نیاز دارد." + "اجازه دسترسی به شبکه محلی" "گزینش رسانه شکست خورد. لطفاً دوباره تلاش کنید." + "روی یک پیام کلیک کنید و «%1$s» را برای گنجاندن در اینجا انتخاب کنید." + "پیام‌های مهم را سنجاق کنید تا به راحتی قابل مشاهده باشند" + + "پیام پین شده %1$d" + "پیام‌های پین‌شده %1$d" + "پیام‌های سنجاق شده" - "داردید برای بازنشانی هویتتان به حساب %1$s می‌روید. پس از آن به کاره برگردانده خواهید شد." + "شما در شرف رفتن به حساب %1$s خود برای تنظیم مجدد هویت دیجیتال خود هستید. پس از آن به برنامه بازگردانده خواهید شد." + "نمی‌توانید تأیید کنید؟ برای بازنشانی هویت دیجیتال خود به حساب کاربری خود بروید." + "تأیید را لغو و ارسال کنید" + "شما می‌توانید تأیید خود را پس بگیرید و در هر صورت این پیام را ارسال کنید، یا می‌توانید فعلاً لغو کنید و پس از تأیید مجدد %1$s، بعداً دوباره امتحان کنید." + "پیام شما ارسال نشد زیرا هویت دیجیتال تأیید شده %1$s بازنشانی شده است." "فرستادن پیام به هر روی" + "%1$s از یک یا چند دستگاه تأیید نشده استفاده می‌کند. در هر صورت می‌توانید پیام را ارسال کنید، یا می‌توانید فعلاً لغو کنید و بعداً پس از تأیید تمام دستگاه‌های %2$s دوباره امتحان کنید." + "پیام شما ارسال نشد زیرا %1$s همه دستگاه‌ها را تأیید نکرده است." + "یک یا چند دستگاه شما تأیید نشده‌اند. در هر صورت می‌توانید پیام را ارسال کنید، یا می‌توانید فعلاً لغو کنید و بعداً پس از تأیید همه دستگاه‌هایتان دوباره امتحان کنید." + "پیام شما ارسال نشد زیرا یک یا چند دستگاه خود را تأیید نکرده‌اید" "ویرایش مدیران یا مالکان" "پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید." "نمی توان جزئیات کاربر را بازیابی کرد" "پیام در %1$s" "گسترش" "کاهش" + "اشتراک گذاری موقعیت مکانی زنده" + "درحال مشاهده این اتاق هستید!" "%1$s از %2$s" "%1$s پیام‌های سنجاق شده" "بار کردن پیام‌ها…" @@ -388,13 +558,30 @@ "گشودن در نقشه‌های گوگل" "گشودن در اوپن‌استریت‌مپ" "هم‌رسانی مکان گزیده" + "گزینه‌های اشتراک‌گذاری" "فضاهایی که ساخته یا پیوسته‌اید." "%1$s • %2$s" + "ایجاد فضاهایی برای سازماندهی اتاق‌ها" "‏%1$s فضا" "فضاها" + "%1$s اشتراک‌گذاری شده" + "روی نقشه" + + "%1$d عضو شد" + "%1$d عضو شد" + + "پیام ارسال نشد زیرا هویت دیجیتال تأیید شده %1$s بازنشانی شده است." + "پیام ارسال نشد زیرا %1$s همه دستگاه‌ها را تأیید نکرده است." + "پیام ارسال نشد زیرا شما یک یا چند دستگاه خود را تأیید نکرده‌اید." "مکان" "نگارش : %1$s (%2$s)" "fa" "en" + "پیام‌های تاریخی در این دستگاه موجود نیست" + "برای دسترسی به پیام‌های قدیمی، باید این دستگاه را تأیید کنید" "به این پیام دسترسی ندارید" + "رمزگشایی پیام ممکن نیست" + "این پیام یا به این دلیل مسدود شده است که شما دستگاه خود را تأیید نکرده‌اید یا به این دلیل که فرستنده باید هویت دیجیتال شما را تأیید کند." + "دستگاه شما خیلی قدیمی است، دستگاهی با اندروید ۸ یا جدیدتر مورد نیاز است." + "رندر کردن نقشه‌ها پشتیبانی نمی‌شود" diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index 52d3bf16e5..b1cc4889a8 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -8,6 +8,7 @@ "Pienennä viestin tekstikenttä" "Vähennä sallittujen äänien määrää per henkilö" "Lisää sallittujen äänien määrää per henkilö" + "Mukautettu emoji" "Poista" "%1$d numero syötetty" @@ -18,11 +19,21 @@ "Täysi osoite tulee olemaan %1$s" "Salauksen tiedot" "Laajenna viestin tekstikenttä" + "Galleria, liite %1$d" + "Galleria, epäonnistunut skannaus %1$d" + "Galleria, kuva %1$d" + + "Galleria, %1$d media lisää" + "Galleria, %1$d mediaa lisää" + + "Galleria, skannataan %1$d" + "Galleria, video %1$d" "Piilota salasana" "Tiedot" "Liity puheluun" "Siirry loppuun" "Siirry lukemattomiin" + "Siirry ensimmäiseen lukemattomaan viestiin" "Siirrä kartta sijaintiini" "Vain maininnat" "Mykistetty" @@ -63,6 +74,7 @@ "Aloita puhelu" "Aloita videopuhelu" "Aloita äänipuhelu" + "Tila" "Viestiketju huoneessa %1$s" "Viestiketjut huoneessa %1$s" "Haudattu huone" @@ -143,6 +155,7 @@ "Hallitse tiliä ja laitteita" "Hallitse laitteita" "Huoneiden hallitseminen" + "Merkitse luetuksi" "Lähetä viesti" "Pienennä" "Seuraava" @@ -503,6 +516,14 @@ Haluatko varmasti jatkaa?" "%1$s Android" "Raivostunut ravistaminen ilmoittaa virheestä" "Näyttökuva" + "Kirjaudu sisään" + "Luo tili" + "Jos tilisi on yrityksesi tai yhteisön tarjoama, vaihda palveluntarjoaja (esimerkki: companyname.com)." + "Luo tili matrix.org -palvelimelle ilmaiseksi. Jos haluat luoda tilin eri palveluntarjoajan kautta, muuta sitä vastaavasti (esimerkiksi: communityname.org)." + "Palveluntarjoaja" + "example.com" + "Anna palveluntarjoajasi" + "Valitse palveluntarjoaja" "%1$s: %2$s" "Vaihtoehdot" "Poista %1$s" @@ -515,6 +536,8 @@ Haluatko varmasti jatkaa?" "%1$d henkilöä" "Kartalla" + "Kotipalvelimesi on paikallisverkossasi. Yhdistääksesi, %1$s tarvitsee luvan tavoittaa laitteita tässä verkossa." + "Salli pääsy paikallisverkkoon" "Median valinta epäonnistui, yritä uudelleen." "Paina viestiä ja valitse “%1$s” lisätäksesi sen tänne." "Kiinnitä tärkeät viestit, jotta ne löytyvät helposti." diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 8d550ae51e..105ced1ad5 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -6,6 +6,9 @@ "Avatar" "Effacer le champ de recherche" "Réduire la taille du composeur" + "Diminuer le nombre de votes autorisés par personne" + "Augmenter le nombre de votes autorisés par personne" + "Émojis personnalisés" "Supprimer" "%1$d chiffre saisi" @@ -16,11 +19,21 @@ "L’adresse complète sera %1$s" "Détails du chiffrement" "Augmenter la taille du composeur" + "Galerie, élément %1$d" + "Galerie, échec de l’analyse de l’élément %1$d" + "Galerie, image %1$d" + + "Galerie, %1$d média de plus" + "Galerie, %1$d média de plus" + + "Galerie, analyse en cours de l’élément %1$d" + "Galerie, vidéo %1$d" "Masquer le mot de passe" "Info" "Rejoindre l’appel" "Retourner à la fin de la conversation" "Aller aux messages non lus" + "Aller au premier message non lu" "Déplacer la carte vers ma position" "Mentions uniquement" "En sourdine" @@ -31,6 +44,7 @@ "Page %1$d" "Pause" "Message vocal, durée: %1$s, position actuelle: %2$s" + "Aperçu de la photo" "Code PIN" "Position épinglée" "Lecture" @@ -41,12 +55,13 @@ "Code QR" "Réagir avec %1$s" "Réagir avec d’autres émojis" - "Lu par %1$s et %2$s" + "Message précédent lu par %1$s et %2$s" - "Lu par %1$s et %2$d autre" - "Lu par %1$s et %2$d autres" + "Message précédent lu par %1$s et %2$d autre" + "Message précédent lu par %1$s et %2$d autres" - "Lu par %1$s" + "Message précédent lu par %1$s" + "afficher tous les accusés de réception" "Supprimer la réaction avec %1$s" "Supprimer la réaction avec %1$s" "Avatar du salon" @@ -59,11 +74,13 @@ "Démarrer un appel" "Passer un appel vidéo" "Lancer un appel vocal" + "Statut" "Discussion dans %1$s" "Discussions dans %1$s" "Salon clôturé" "Avatar de l’utilisateur" "Menu utilisateur" + "Aperçu vidéo" "Voir l’avatar" "Afficher les détails" "Message vocal, durée: %1$s" @@ -138,6 +155,7 @@ "Gérer le compte et les appareils" "Gérez les sessions" "Gérer les salons" + "Marquer comme lu" "Message" "Minimiser" "Suivant" @@ -192,9 +210,11 @@ "Traduire" "Essayer à nouveau" "Désépingler" + "Appel vidéo" "Voir" "Voir dans la discussion" "Afficher la source" + "Appel audio" "Oui" "Oui, réessayez" "Votre serveur prend désormais en charge un nouveau protocole plus rapide. Déconnectez-vous, puis reconnectez-vous pour effectuer la mise à niveau dès maintenant. En le faisant tout de suite, vous éviterez une déconnexion forcée lorsque l’ancien protocole sera supprimé." @@ -215,6 +235,7 @@ "Utilisateurs bloqués" "Bulles" "Appel rejeté" + "Appel en cours" "Appel démarré" "Vous avez rejeté un appel" "Sauvegarde des discussions" @@ -258,7 +279,17 @@ Raison : %1$s." "Fichier enregistré dans Téléchargements" "Transférer le message" "Fréquemment utilisé" + "Galerie" + + "%1$d élément…" + "%1$d éléments…" + + + "%1$d élément multimédia…" + "%1$d éléments multimédia…" + "GIF" + "Appel de groupe en cours" "Image" "En réponse à %1$s" "Installer l’APK" @@ -294,6 +325,7 @@ Raison : %1$s." "Espace sans nom" "Non chiffré" "Hors ligne" + "En appel" "Licences open source" "ou" "Autres options" @@ -401,6 +433,7 @@ Raison : %1$s." "Retirer la sourdine" "Appel non pris en charge" "Événement non pris en charge" + "%1$s a commencé un appel" "Nom d’utilisateur" "Vérification annulée" "Vérification terminée" @@ -422,6 +455,11 @@ Raison : %1$s." "En attente de la position en temps réel…" "Tout le monde peut voir les anciens messages" "Vous" + "Téléchargement indisponible." + "Une erreur est survenue" + "Analyse en cours…" + "L’aperçu et le téléchargement ont été désactivés." + "Le fichier n’est pas sûr" "%1$s (%2$s) a partagé ce message avec vous car vous n’étiez pas dans le salon lors de son envoi." "%1$s a partagé ce message avec vous car vous n’étiez pas dans le salon lors de son envoi." "Ce salon a été configuré pour que les nouveaux membres puissent lire l’historique. %1$s" @@ -478,10 +516,19 @@ Raison : %1$s." "%1$s Android" "Rageshake pour signaler un problème" "Capture d’écran" + "Se connecter" + "Créer un compte" + "Si votre fournisseur de compte est fourni par votre entreprise ou une communauté, veuillez le saisir ici (exemple : monentreprise.com)." + "Créez un compte sur matrix.org gratuitement. Si vous souhaitez créer un compte auprès d’un autre fournisseur, veuillez modifier l’adresse ici (exemple : monassociation.org)." + "Fournisseur de compte" + "exemple.com" + "Saisissez votre fournisseur de compte" + "Choisissez un fournisseur de compte" "%1$s: %2$s" "Options" "Supprimer %1$s" "Paramètres" + "Nombre de votes autorisés par personne" "Personne ne partage sa position" "Partage de la position en direct" @@ -489,6 +536,8 @@ Raison : %1$s." "%1$d personnes" "Sur la carte" + "Votre fournisseur de compte se trouve sur votre réseau local. Pour pouvoir communiquer, %1$s a besoin de l’autorisation d’accéder aux appareils de ce réseau." + "Autoriser l’accès au réseau local" "Échec de la sélection du média, veuillez réessayer." "Cliquez (clic long) sur un message et choisissez « %1$s » pour qu‘il apparaisse ici." "Épinglez les messages importants pour leur donner plus de visibilité" @@ -534,6 +583,10 @@ Raison : %1$s." "Espaces" "Partagé %1$s" "Sur la carte" + + "%1$d participant" + "%1$d participants" + "Le message n’a pas été envoyé car l’identité numérique vérifiée de %1$s a été réinitialisée." "Le message n’a pas été envoyé car %1$s n’a pas vérifié tous ses appareils." "Message non envoyé car vous n’avez pas vérifié tous vos appareils." diff --git a/libraries/voicerecorder/test/build.gradle.kts b/libraries/voicerecorder/test/build.gradle.kts index c0f74b71e8..f54ef0e8d3 100644 --- a/libraries/voicerecorder/test/build.gradle.kts +++ b/libraries/voicerecorder/test/build.gradle.kts @@ -19,6 +19,5 @@ dependencies { implementation(projects.tests.testutils) implementation(libs.coroutines.test) - implementation(libs.test.truth) implementation(projects.libraries.core) } diff --git a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt index ec62227857..b735bb9fa5 100644 --- a/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt +++ b/libraries/voicerecorder/test/src/main/kotlin/io/element/android/libraries/voicerecorder/test/FakeVoiceRecorder.kt @@ -8,13 +8,13 @@ package io.element.android.libraries.voicerecorder.test -import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.yield import java.io.File import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -23,51 +23,47 @@ import kotlin.time.TestTimeSource class FakeVoiceRecorder( private val timeSource: TestTimeSource = TestTimeSource(), private val recordingDuration: Duration = 0.seconds, - private val levels: List = listOf(0.1f, 0.2f) + private val levels: List = listOf(0.1f, 0.2f), + val waveform: List = A_WAVEFORM, + private val startRecordResult: () -> Unit = { lambdaError() }, + private val stopRecordResult: (Boolean) -> Unit = { lambdaError() }, + private val deleteRecordingResult: () -> Unit = { lambdaError() }, ) : VoiceRecorder { private val _state = MutableStateFlow(VoiceRecorderState.Idle) override val state: StateFlow = _state - private var curRecording: File? = null + private val levelInterval = if (levels.isEmpty()) Duration.ZERO else recordingDuration / levels.size - private var securityException: SecurityException? = null + private var currentRecording: File? = null + private var lastRecordingId = 0 + private var activeRecordingId = 0 - private var startedCount = 0 - private var stoppedCount = 0 - private var deletedCount = 0 - - var waveform: List = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f) override suspend fun startRecord() { - startedCount += 1 + startRecordResult() + if (activeRecordingId != 0) return + val recordingId = ++lastRecordingId + activeRecordingId = recordingId + currentRecording = File("file.ogg") val startedAt = timeSource.markNow() - securityException?.let { throw it } - - if (curRecording != null) { - error("Previous recording was not cleared") - } - curRecording = File("file.ogg") - - timeSource += recordingDuration for (i in 1..levels.size) { + delay(levelInterval) + if (activeRecordingId != recordingId) return + timeSource += levelInterval _state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i))) - yield() } } - override suspend fun stopRecord( - cancelled: Boolean - ) { - stoppedCount++ - + override suspend fun stopRecord(cancelled: Boolean) { + stopRecordResult(cancelled) + activeRecordingId = 0 if (cancelled) { deleteRecording() } - _state.emit( - when (curRecording) { + when (val file = currentRecording) { null -> VoiceRecorderState.Idle else -> VoiceRecorderState.Finished( - file = curRecording!!, + file = file, mimeType = MimeTypes.Ogg, duration = recordingDuration, waveform = waveform, @@ -77,25 +73,11 @@ class FakeVoiceRecorder( } override suspend fun deleteRecording() { - deletedCount++ - curRecording = null - - _state.emit( - VoiceRecorderState.Idle - ) - } - - fun assertCalls( - started: Int = 0, - stopped: Int = 0, - deleted: Int = 0, - ) { - assertThat(startedCount).isEqualTo(started) - assertThat(stoppedCount).isEqualTo(stopped) - assertThat(deletedCount).isEqualTo(deleted) - } - - fun givenThrowsSecurityException(exception: SecurityException) { - this.securityException = exception + deleteRecordingResult() + activeRecordingId = 0 + currentRecording = null + _state.emit(VoiceRecorderState.Idle) } } + +private val A_WAVEFORM = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f) diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt deleted file mode 100644 index c7bc602dde..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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.libraries.wellknown.api - -/** - * Server-driven settings for a user-chosen recovery passphrase, advertised under the well-known - * `custom_recovery_passphrase` key. Today the only rule is a minimum character count; additional - * settings can be added here as the schema grows. - */ -data class CustomRecoveryPassphrase( - val minCharacterCount: Int, -) { - /** True when [input] meets every active rule. */ - fun isSatisfiedBy(input: String): Boolean = isSatisfiedBy(input.length) - - /** True when an input of [length] characters meets every active rule. */ - fun isSatisfiedBy(length: Int): Boolean = length >= minCharacterCount -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnownParser.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnownParser.kt deleted file mode 100644 index 1466a5b422..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnownParser.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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.libraries.wellknown.api - -/** - * Parses the raw JSON of an Element `.well-known` document. - */ -fun interface ElementWellKnownParser { - /** - * Parses the document, failing when it is not valid JSON; unknown fields are ignored so a newer server does not break an older client. - * - * @param json the raw document as served by the server. - */ - fun parse(json: String): Result -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt deleted file mode 100644 index 93256fa6f1..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.libraries.wellknown.api - -/** - * On-disk cache of the Element `.well-known` documents already fetched, so the app has a configuration to work with while offline. - */ -interface ElementWellknownStore { - /** - * Reads the cached document of a domain. - * Entries older than a day are reported as outdated rather than missing, so the caller can use them while refreshing; - * a document that no longer parses is reported as an error. - * - * @param domain the server whose cached document is requested. - */ - suspend fun get(domain: String): WellknownRetrieverResult - - /** - * Stores the document of a domain and stamps it as fetched now, replacing any previous entry. - * - * @param domain the server the document belongs to. - * @param wellknown the raw document, stored as-is rather than parsed. - */ - suspend fun update(domain: String, wellknown: String): Result - - /** - * Removes the cached document of a domain, used when the stored value turns out to be unusable. - * - * @param domain the server whose entry is removed. - */ - suspend fun delete(domain: String): Result - - /** - * Creates a store, one per configuration source so that their entries never collide. - */ - fun interface Factory { - /** - * @param prefix namespaces the cache keys, or `null` for the default well-known endpoint. - */ - fun create(prefix: String?): ElementWellknownStore - } -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/EnterpriseRemoteConfigSource.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/EnterpriseRemoteConfigSource.kt deleted file mode 100644 index 147f31269d..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/EnterpriseRemoteConfigSource.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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.libraries.wellknown.api - -/** - * The source from which the enterprise remote configuration is retrieved. - */ -enum class EnterpriseRemoteConfigSource { - /** - * Retrieve the enterprise remote configuration from the Element Server Suite configuration (ESS_CONFIG) endpoint. - */ - ESS_CONFIG, - - /** - * Retrieve the enterprise remote configuration from the Element server's well-known endpoint. - */ - WELLKNOWN_ENDPOINT, -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt deleted file mode 100644 index 9e265bc213..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetriever.kt +++ /dev/null @@ -1,39 +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.libraries.wellknown.api - -import io.element.android.libraries.matrix.api.UrlContentFetcher - -/** - * Retrieves the Element specific `.well-known` configuration of a server, which is how a deployment advertises its own settings. - */ -interface WellknownRetriever { - /** - * Returns the configuration of a host, served from the local cache when possible. - * A cached value that has gone stale is returned as outdated while a refresh runs in the background, so this rarely waits on the network. - * An enterprise build with a hardcoded configuration always short-circuits and returns that instead. - * - * @param host the server to look up; a full URL is accepted and reduced to its host. - * @param source which endpoint to read the configuration from, which also selects the cache it is stored in. - */ - suspend fun getElementWellKnown( - host: String, - source: EnterpriseRemoteConfigSource - ): WellknownRetrieverResult - - /** - * Creates a retriever, which needs a fetcher because the configuration is read over HTTP. - */ - interface Factory { - /** - * @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works. - */ - fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever - } -} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt deleted file mode 100644 index 98af981a66..0000000000 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt +++ /dev/null @@ -1,45 +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.libraries.wellknown.api - -sealed interface WellknownRetrieverResult { - /** - * Well-known data has been successfully retrieved. - */ - data class Success(val data: T) : WellknownRetrieverResult - - /** - * Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched. - */ - data class Outdated(val data: T) : WellknownRetrieverResult - - /** - * Well-known data is not found (file does not exist server side, we got a 404). - */ - data object NotFound : WellknownRetrieverResult - - /** - * Any other error. - */ - data class Error(val exception: Exception) : WellknownRetrieverResult - - fun dataOrNull(): T? = when (this) { - is Success -> data - is Outdated -> data - is Error -> null - NotFound -> null - } - - fun upToDateDataOrNull(): T? = when (this) { - is Success -> data - is Outdated -> null - is Error -> null - NotFound -> null - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultElementWellKnownParser.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultElementWellKnownParser.kt deleted file mode 100644 index 4cf72366e0..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultElementWellKnownParser.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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.libraries.wellknown.impl - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser - -@ContributesBinding(AppScope::class) -class DefaultElementWellKnownParser( - private val jsonProvider: JsonProvider, -) : ElementWellKnownParser { - override fun parse(json: String): Result { - return runCatchingExceptions { - val decoder = jsonProvider() - decoder.decodeFromString(json).map() - } - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt deleted file mode 100644 index 4d58e70db4..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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.libraries.wellknown.impl - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.cachestore.api.CacheStore -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.services.toolbox.api.systemclock.SystemClock - -@AssistedInject -class DefaultWellKnownStore( - @Assisted - private val prefix: String?, - private val cacheStore: CacheStore, - private val elementWellKnownParser: ElementWellKnownParser, - private val systemClock: SystemClock, -) : ElementWellknownStore { - @AssistedFactory - @ContributesBinding(AppScope::class) - fun interface Factory : ElementWellknownStore.Factory { - override fun create(prefix: String?): DefaultWellKnownStore - } - override suspend fun get(domain: String): WellknownRetrieverResult { - return runCatchingExceptions { - val cachedData = cacheStore.getData(key(domain)) - if (cachedData != null) { - val data = elementWellKnownParser.parse(cachedData.value).getOrThrow() - if (systemClock.epochMillis() > cachedData.updatedAt + CACHE_VALIDITY_MILLIS) { - WellknownRetrieverResult.Outdated(data) - } else { - WellknownRetrieverResult.Success(data) - } - } else { - WellknownRetrieverResult.NotFound - } - }.recover { - WellknownRetrieverResult.Error(it as Exception) - }.getOrThrow() - } - - override suspend fun update(domain: String, wellknown: String): Result { - return runCatchingExceptions { - cacheStore.storeData(key(domain), CacheData(wellknown, systemClock.epochMillis())) - } - } - - override suspend fun delete(domain: String): Result { - return runCatchingExceptions { - cacheStore.deleteData(key(domain)) - } - } - - private fun key(domain: String): String = if (prefix != null) { - "$prefix-$domain" - } else { - "https://$domain/.well-known/element/element.json" - } - - companion object { - // 1 day - private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt deleted file mode 100644 index 60e8c621e2..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt +++ /dev/null @@ -1,142 +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.libraries.wellknown.impl - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.androidutils.text.takeIfNotBlank -import io.element.android.libraries.core.extensions.mapCatchingExceptions -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.uri.ensureProtocol -import io.element.android.libraries.di.annotations.AppCoroutineScope -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.exception.ClientException -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import timber.log.Timber -import java.net.URL - -@AssistedInject -class DefaultWellknownRetriever( - private val elementWellknownStoreFactory: ElementWellknownStore.Factory, - private val enterpriseService: EnterpriseService, - private val elementWellKnownParser: ElementWellKnownParser, - @Assisted private val urlContentFetcher: UrlContentFetcher, - @AppCoroutineScope private val coroutineScope: CoroutineScope, -) : WellknownRetriever { - @ContributesBinding(AppScope::class) - @AssistedFactory - fun interface Factory : WellknownRetriever.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): DefaultWellknownRetriever - } - - override suspend fun getElementWellKnown( - host: String, - source: EnterpriseRemoteConfigSource - ): WellknownRetrieverResult { - val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown() - if (overriddenElementWellKnown != null) { - return WellknownRetrieverResult.Success(overriddenElementWellKnown) - } - - // The passed `host` function may be a full URL, in that case we're only interested in the actual host - val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() } - .getOrElse { return WellknownRetrieverResult.Error(IllegalArgumentException("host parameter is not valid", it)) } - ?: host - - // We instantiate different stores for different sources, so that we can cache them separately. - // The ESS_CONFIG source is cached with a prefix to differentiate it from the WELLKNOWN_ENDPOINT. - val prefix = when (source) { - EnterpriseRemoteConfigSource.ESS_CONFIG -> "ess_config" - EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> null - } - val store = elementWellknownStoreFactory.create(prefix) - return when (val cacheData = store.get(checkedHost)) { - is WellknownRetrieverResult.Success -> { - Timber.d("Using cached well-known for domain $checkedHost") - cacheData - } - is WellknownRetrieverResult.Outdated -> { - // Return the outdated data but refresh in the background - // If the cache is missing or outdated, trigger a refresh in background but still return the cached value - Timber.d("Outdated cached well-known for domain $checkedHost, returning existing value and fetching new one from network") - coroutineScope.launch { fetchElementWellKnown(checkedHost, source, store) } - cacheData - } - is WellknownRetrieverResult.NotFound -> { - // Try to fetch from the server - Timber.d("No cached well-known for domain $checkedHost, fetching from network") - fetchElementWellKnown(checkedHost, source, store) - } - is WellknownRetrieverResult.Error -> { - // Return the error - Timber.e(cacheData.exception, "Error retrieving well-known for domain $checkedHost") - cacheData.exception.toWellknownRetrieverResult() - } - } - } - - private suspend fun fetchElementWellKnown( - host: String, - source: EnterpriseRemoteConfigSource, - store: ElementWellknownStore - ): WellknownRetrieverResult { - return urlContentFetcher - .getUrl(url(host, source)) - .mapCatchingExceptions { - val data = String(it) - val parsed = elementWellKnownParser.parse(data).getOrThrow() - // Also store in cache, if valid - store.update(host, data) - .onFailure { exception -> - Timber.e(exception, "Failed to parse cached Element .well-known data for $host, deleting cache") - store.delete(host) - } - parsed - } - .toWellknownRetrieverResult(host) - } - - private fun Result.toWellknownRetrieverResult(host: String): WellknownRetrieverResult = fold( - onSuccess = { - WellknownRetrieverResult.Success(it) - }, - onFailure = { - Timber.e(it, "Failed to retrieve Element .well-known from $host") - // This check on message value is not ideal but this is what we got from the SDK. - it.toWellknownRetrieverResult() - } - ) - - private fun Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult { - return if ((this as? ClientException.Generic)?.message?.contains("404") == true) { - WellknownRetrieverResult.NotFound - } else { - WellknownRetrieverResult.Error(this as Exception) - } - } - - private fun url(host: String, source: EnterpriseRemoteConfigSource): String { - val elementWellKnownUrl = "https://$host/.well-known/element/element.json" - return when (source) { - EnterpriseRemoteConfigSource.ESS_CONFIG -> enterpriseService.essConfigEndpointUrl(host) ?: elementWellKnownUrl - EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> elementWellKnownUrl - } - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt deleted file mode 100644 index 317109a1c0..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt +++ /dev/null @@ -1,71 +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.libraries.wellknown.impl - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Example: - *
- * {
- *     "registration_helper_url": "https://element.io",
- *     "enforce_element_pro": true,
- *     "rageshake_url": "https://example.org/rageshake",
- *     "brand_color": "#FF0000",
- *     "notification_sound": "ring.flac",
- *     "idp_app_scheme": "io.element.app",
- *     "custom_recovery_passphrase": {
- *         "min_character_count": 8
- *     }
- * }
- * 
- * . - */ -@Serializable -data class InternalElementWellKnown( - @SerialName("registration_helper_url") - val registrationHelperUrl: String? = null, - @SerialName("enforce_element_pro") - val enforceElementPro: Boolean? = null, - @SerialName("rageshake_url") - val rageshakeUrl: String? = null, - @SerialName("brand_color") - val brandColor: String? = null, - @SerialName("notification_sound") - val notificationSound: String? = null, - @SerialName("idp_app_scheme") - val identityProviderAppScheme: String? = null, - @SerialName("custom_recovery_passphrase") - val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null, - @SerialName("content_scanner_url") - val contentScannerUrl: String? = null, - @SerialName("force_disable_e2ee") - val forceDisableE2EE: Boolean? = null, - @SerialName("map_tiler") - val mapTilerConfig: InternalMapTilerConfig? = null, -) - -@Serializable -data class InternalCustomRecoveryPassphrase( - @SerialName("min_character_count") - val minCharacterCount: Int? = null, -) - -@Serializable -data class InternalMapTilerConfig( - @SerialName("api_key") - val apiKey: String, - @SerialName("light_style_id") - val lightStyleId: String? = null, - @SerialName("dark_style_id") - val darkStyleId: String? = null, - @SerialName("base_url") - val baseUrl: String? = null, -) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnown.kt deleted file mode 100644 index bf43356c45..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnown.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.libraries.wellknown.impl - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery - *
- * {
- *     "m.homeserver": {
- *         "base_url": "https://matrix.org"
- *     },
- *     "m.identity_server": {
- *         "base_url": "https://vector.im"
- *     },
- * }
- * 
- * . - */ -@Serializable -data class InternalWellKnown( - @SerialName("m.homeserver") - val homeServer: InternalWellKnownBaseConfig? = null, - @SerialName("m.identity_server") - val identityServer: InternalWellKnownBaseConfig? = null, -) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnownBaseConfig.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnownBaseConfig.kt deleted file mode 100644 index 342cd43395..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalWellKnownBaseConfig.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.libraries.wellknown.impl - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery - *
- * {
- *     "base_url": "https://element.io"
- * }
- * 
- * . - */ -@Serializable -data class InternalWellKnownBaseConfig( - @SerialName("base_url") - val baseURL: String? = null -) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt deleted file mode 100644 index 254036c5c3..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ /dev/null @@ -1,59 +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.libraries.wellknown.impl - -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.MapTilerConfig -import timber.log.Timber - -private val loggerTag = LoggerTag("Wellknown") - -/** - * Floor for the recovery passphrase minimum length. A value of 1 guarantees the derived passphrase is - * never empty (an empty passphrase derives a recoverable but effectively unprotected secret-storage - * key). Anything stronger than non-empty is left to the homeserver operator's configuration. - */ -private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1 - -fun InternalElementWellKnown.map() = ElementWellKnown( - registrationHelperUrl = registrationHelperUrl, - enforceElementPro = enforceElementPro, - rageshakeUrl = rageshakeUrl, - brandColor = brandColor, - notificationSound = notificationSound, - identityProviderAppScheme = identityProviderAppScheme, - customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(), - contentScannerUrl = contentScannerUrl, - forceDisableE2EE = forceDisableE2EE, - mapTilerConfig = mapTilerConfig?.let { - MapTilerConfig( - apiKey = it.apiKey, - lightStyleId = it.lightStyleId, - darkStyleId = it.darkStyleId, - baseUrl = it.baseUrl, - ) - } -) - -private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase { - // Whenever the homeserver advertises the settings block we run the custom passphrase flow, flooring - // the minimum at 1 so the passphrase can never be empty even if the server omits min_character_count - // or advertises a non-positive value. The operator owns any stronger minimum. - val min = (minCharacterCount ?: MINIMUM_PASSPHRASE_CHARACTER_COUNT).coerceAtLeast(MINIMUM_PASSPHRASE_CHARACTER_COUNT) - if (min != minCharacterCount) { - Timber.tag(loggerTag.value).w( - "custom_recovery_passphrase.min_character_count was %s; flooring to %d", - minCharacterCount, - min, - ) - } - return CustomRecoveryPassphrase(minCharacterCount = min) -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/SessionWellknownModule.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/SessionWellknownModule.kt deleted file mode 100644 index 1587e95765..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/SessionWellknownModule.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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.libraries.wellknown.impl - -import dev.zacsweers.metro.BindingContainer -import dev.zacsweers.metro.ContributesTo -import dev.zacsweers.metro.Provides -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.WellknownRetriever -import kotlinx.coroutines.CoroutineScope - -@BindingContainer -@ContributesTo(SessionScope::class) -object SessionWellknownModule { - @Provides - fun provideWellKnownRetriever( - elementWellknownStoreFactory: ElementWellknownStore.Factory, - enterpriseService: EnterpriseService, - elementWellKnownParser: ElementWellKnownParser, - urlContentFetcher: UrlContentFetcher, - @SessionCoroutineScope coroutineScope: CoroutineScope, - ): WellknownRetriever { - return DefaultWellknownRetriever( - elementWellknownStoreFactory = elementWellknownStoreFactory, - enterpriseService = enterpriseService, - elementWellKnownParser = elementWellKnownParser, - urlContentFetcher = urlContentFetcher, - coroutineScope = coroutineScope - ) - } -} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/WellknownAPI.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/WellknownAPI.kt deleted file mode 100644 index 54528f7c42..0000000000 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/WellknownAPI.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-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.libraries.wellknown.impl - -import retrofit2.http.GET - -internal interface WellknownAPI { - @GET(".well-known/matrix/client") - suspend fun getWellKnown(): InternalWellKnown - - @GET(".well-known/element/element.json") - suspend fun getElementWellKnown(): InternalElementWellKnown -} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt deleted file mode 100644 index d4e7ac112e..0000000000 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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.libraries.wellknown.api - -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class CustomRecoveryPassphraseTest { - @Test - fun `empty input never satisfies a positive minimum`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 1).isSatisfiedBy("")).isFalse() - } - - @Test - fun `input shorter than minimum is not satisfied`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse() - } - - @Test - fun `input exactly at minimum is satisfied`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue() - } - - @Test - fun `input longer than minimum is satisfied`() { - assertThat(CustomRecoveryPassphrase(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue() - } -} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt deleted file mode 100644 index dc101c41a0..0000000000 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * 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.libraries.wellknown.impl - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.wellknown.test.anElementWellKnown -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP -import io.element.android.services.toolbox.test.systemclock.FakeSystemClock -import kotlinx.coroutines.test.runTest -import org.junit.Test - -private const val A_DOMAIN = "matrix.example.com" -private const val A_CACHE_KEY = "https://$A_DOMAIN/.well-known/element/element.json" - -// 1 day in millis -private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L - -class DefaultWellKnownStoreTest { - private val jsonProvider = DefaultJsonProvider() - - @Test - fun `get returns NotFound when cache is empty`() = runTest { - val sut = createDefaultWellKnownStore() - assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound) - } - - @Test - fun `get returns Success when cache has fresh data`() = runTest { - val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") - val jsonString = jsonProvider().encodeToString(wellKnown) - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map())) - } - - @Test - fun `get returns Outdated when cached data has expired`() = runTest { - val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") - val jsonString = jsonProvider().encodeToString(wellKnown) - // Store data at timestamp 0, but clock is past the validity window - val expiredTimestamp = 0L - val nowTimestamp = expiredTimestamp + CACHE_VALIDITY_MILLIS + 1 - val clock = FakeSystemClock(epochMillisResult = nowTimestamp) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, expiredTimestamp)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isEqualTo(WellknownRetrieverResult.Outdated(wellKnown.map())) - } - - @Test - fun `get returns Error when cached data is invalid JSON`() = runTest { - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData("not valid json at all", A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `update stores data and get returns Success`() = runTest { - val wellKnown = """ - { - "registration_helper_url": "https://element.io", - "enforce_element_pro": true, - "rageshake_url": "https://example.org/rageshake", - "brand_color": "#FF0000", - "notification_sound": "ring.flac", - "idp_app_scheme": "io.element.app", - "custom_recovery_passphrase": { - "min_character_count": 8 - } - } - """.trimIndent() - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore() - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val updateResult = sut.update(A_DOMAIN, wellKnown) - assertThat(updateResult.isSuccess).isTrue() - - val result = sut.get(A_DOMAIN) - assertThat(result).isEqualTo(WellknownRetrieverResult.Success(anElementWellKnown( - registrationHelperUrl = "https://element.io", - enforceElementPro = true, - rageshakeUrl = "https://example.org/rageshake", - brandColor = "#FF0000", - notificationSound = "ring.flac", - identityProviderAppScheme = "io.element.app", - customRecoveryPassphrase = CustomRecoveryPassphrase(8), - ))) - } - - @Test - fun `delete removes data and get returns NotFound`() = runTest { - val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com") - val jsonString = jsonProvider().encodeToString(wellKnown) - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val deleteResult = sut.delete(A_DOMAIN) - assertThat(deleteResult.isSuccess).isTrue() - - assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound) - } - - @Test - fun `get returns data at the exact cache validity boundary as Success`() = runTest { - val wellKnown = InternalElementWellKnown(notificationSound = "ping.flac") - val jsonString = jsonProvider().encodeToString(wellKnown) - // Exactly at the boundary: epochMillis == updatedAt + CACHE_VALIDITY_MILLIS → not expired - val storedAt = 1000L - val nowAtBoundary = storedAt + CACHE_VALIDITY_MILLIS - val clock = FakeSystemClock(epochMillisResult = nowAtBoundary) - val cacheStore = InMemoryCacheStore( - initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, storedAt)) - ) - val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock) - - val result = sut.get(A_DOMAIN) - - assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map())) - } - - @Test - fun `prefix is used`() = runTest { - val prefix = "test-prefix" - val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP) - val cacheStore = InMemoryCacheStore( - initialData = mapOf("$prefix-$A_DOMAIN" to CacheData("{}", A_FAKE_TIMESTAMP)) - ) - val sut = createDefaultWellKnownStore(prefix = prefix, cacheStore = cacheStore, systemClock = clock) - - assertThat(sut.get(A_DOMAIN)).isInstanceOf(WellknownRetrieverResult.Success::class.java) - } - - private fun createDefaultWellKnownStore( - prefix: String? = null, - cacheStore: InMemoryCacheStore = InMemoryCacheStore(), - systemClock: FakeSystemClock = FakeSystemClock(), - elementWellKnownParser: ElementWellKnownParser = DefaultElementWellKnownParser(jsonProvider), - ) = DefaultWellKnownStore( - prefix = prefix, - cacheStore = cacheStore, - systemClock = systemClock, - elementWellKnownParser = elementWellKnownParser, - ) -} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt deleted file mode 100644 index a58aa0a347..0000000000 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt +++ /dev/null @@ -1,241 +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. - */ - -@file:OptIn(ExperimentalCoroutinesApi::class) - -package io.element.android.libraries.wellknown.impl - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeEnterpriseService -import io.element.android.features.wellknown.test.FakeElementWellknownStore -import io.element.android.features.wellknown.test.aMapTilerConfig -import io.element.android.features.wellknown.test.anElementWellKnown -import io.element.android.libraries.core.uri.ensureProtocol -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.matrix.api.exception.ClientException -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellKnownParser -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test -import java.net.URL - -class DefaultWellknownRetrieverTest { - @Test - fun `get element wellknown calls resolver and parser`() = runTest { - val parser = lambdaRecorder> { Result.success(anElementWellKnown()) } - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - val sut = createDefaultWellknownRetriever( - elementWellKnownParser = parser, - urlResolver = resolver, - ) - - sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT) - - resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL)) - parser.assertions().isCalledOnce().with(value(WELLKNOWN_CONTENT)) - } - - @Test - fun `get element wellknown parser error`() = runTest { - val sut = createDefaultWellknownRetriever( - elementWellKnownParser = { Result.failure(IllegalStateException("Invalid JSON")) }, - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown network error`() = runTest { - val resolver = SpyUrlContentFetcher { Result.failure(IllegalStateException("Network error")) } - val sut = createDefaultWellknownRetriever(urlResolver = resolver) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown 404 http error counts as not found`() = runTest { - val resolver = SpyUrlContentFetcher { Result.failure(ClientException.Generic("Http error: 404", null)) } - val sut = createDefaultWellknownRetriever(urlResolver = resolver) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.NotFound::class.java) - } - - @Test - fun `get element wellknown will return an error if the provided host is not valid`() = runTest { - val sut = createDefaultWellknownRetriever() - assertThat(sut.getElementWellKnown("!not valid", EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown will return a cached value if present instead of fetching`() = runTest { - val host = URL(WELLKNOWN_URL).host.ensureProtocol() - val sut = createDefaultWellknownRetriever( - cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Success(expectedElementWellKnown))) } - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Success::class.java) - } - - @Test - fun `get element wellknown will return an outdated value first then fetch the current one`() = runTest { - val host = URL(WELLKNOWN_URL).host.ensureProtocol() - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - val sut = createDefaultWellknownRetriever( - urlResolver = resolver, - cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Outdated(expectedElementWellKnown))) } - ) - // The first call returns the outdated value - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Outdated::class.java) - // The resolver is not called yet because the fetch is done in the background - resolver.assertions().isNeverCalled() - - // We give it some time to run the background fetch - runCurrent() - - // The resolver is called during the background fetch - resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL)) - } - - @Test - fun `get element wellknown will return an error if it happens when trying to retrieve the cached value`() = runTest { - val host = URL(WELLKNOWN_URL).host.ensureProtocol() - val sut = createDefaultWellknownRetriever( - cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Error(IllegalStateException("BOOM")))) } - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `get element wellknown will return an error if it fails to serialize the retrieved value into the cache`() = runTest { - val sut = createDefaultWellknownRetriever( - elementWellKnownParser = { Result.failure(IllegalStateException("BOOM")) }, - ) - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) - .isInstanceOf(WellknownRetrieverResult.Error::class.java) - } - - @Test - fun `WELLKNOWN_ENDPOINT source creates a store with no prefix`() = runTest { - val storeFactory = lambdaRecorder { FakeElementWellknownStore() } - val sut = createDefaultWellknownRetriever(cacheStoreFactory = storeFactory) - - sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT) - - storeFactory.assertions().isCalledOnce().with(value(null)) - } - - @Test - fun `ESS_CONFIG source creates a store with ess prefix and checks the ESS config endpoint`() = runTest { - val essConfigUrl = "https://ess-config.example.com" - val storeFactory = lambdaRecorder { FakeElementWellknownStore() } - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - val sut = createDefaultWellknownRetriever( - cacheStoreFactory = storeFactory, - enterpriseService = FakeEnterpriseService( - overrideWellKnownResult = { null }, - essConfigEndpointUrlResult = { essConfigUrl }, - ), - urlResolver = resolver, - ) - - sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.ESS_CONFIG) - - storeFactory.assertions().isCalledOnce().with(value("ess_config")) - resolver.assertions().isCalledOnce().with(value(essConfigUrl)) - } - - @Test - fun `get element wellknown was overridden`() = runTest { - val wellKnown = anElementWellKnown() - - val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } - - val sut = createDefaultWellknownRetriever( - enterpriseService = FakeEnterpriseService( - overrideWellKnownResult = { wellKnown }, - ), - urlResolver = resolver, - ) - - // The overridden value is returned - assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)).isEqualTo( - WellknownRetrieverResult.Success(wellKnown) - ) - - // And the endpoint is never hit - resolver.assertions().isNeverCalled() - } - - private fun TestScope.createDefaultWellknownRetriever( - cacheStoreFactory: ElementWellknownStore.Factory = { FakeElementWellknownStore() }, - elementWellKnownParser: ElementWellKnownParser = { Result.success(expectedElementWellKnown) }, - enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }), - urlResolver: UrlContentFetcher = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }, - ) = DefaultWellknownRetriever( - elementWellknownStoreFactory = cacheStoreFactory, - enterpriseService = enterpriseService, - elementWellKnownParser = elementWellKnownParser, - urlContentFetcher = urlResolver, - coroutineScope = backgroundScope, - ) - - companion object { - private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json" - private const val WELLKNOWN_CONTENT = """{ - "registration_helper_url": "a_registration_url", - "enforce_element_pro": true, - "rageshake_url": "a_rageshake_url", - "brand_color": "#FF0000", - "notification_sound": "a_notification_sound.flac", - "idp_app_scheme": "an_app_scheme", - "content_scanner_url": "https://content-scanner.example.com", - "force_disable_e2ee": false, - "map_tiler": { - "api_key": "test_api_key", - "base_url": "https://tile-server.example.com" - } - }""" - - private val expectedElementWellKnown = ElementWellKnown( - registrationHelperUrl = "a_registration_url", - enforceElementPro = true, - rageshakeUrl = "a_rageshake_url", - brandColor = "#FF0000", - notificationSound = "a_notification_sound.flac", - identityProviderAppScheme = "an_app_scheme", - contentScannerUrl = "https://content-scanner.example.com", - customRecoveryPassphrase = null, - forceDisableE2EE = false, - mapTilerConfig = aMapTilerConfig( - apiKey = "test_api_key", - baseUrl = "https://tile-server.example.com", - ) - ) - } -} - -private class SpyUrlContentFetcher(lambda: (String) -> Result) : UrlContentFetcher { - private val spy = lambdaRecorder> { lambda(it) } - - override suspend fun getUrl(url: String): Result { - return spy(url) - } - - fun assertions() = spy.assertions() -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt deleted file mode 100644 index c5e2d2ae5b..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.wellknown.test - -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.ElementWellknownStore -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult -import io.element.android.libraries.wellknown.impl.InternalElementWellKnown -import io.element.android.libraries.wellknown.impl.map -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.JsonDecodingException -import java.net.URL - -class FakeElementWellknownStore( - initialData: Map> = emptyMap(), - private val jsonProvider: JsonProvider = DefaultJsonProvider(), -) : ElementWellknownStore { - private val cachedData = mutableMapOf>() - - init { - initialData.forEach { (url, result) -> - cachedData[URL(url).host] = result - } - } - - override suspend fun get(domain: String): WellknownRetrieverResult { - return cachedData[domain] ?: WellknownRetrieverResult.NotFound - } - - @OptIn(ExperimentalSerializationApi::class) - override suspend fun update(domain: String, wellknown: String): Result { - val json = jsonProvider() - val mapped = try { - json.decodeFromString(wellknown).map() - } catch (e: JsonDecodingException) { - return Result.failure(e) - } - cachedData[domain] = WellknownRetrieverResult.Success(mapped) - return Result.success(Unit) - } - - override suspend fun delete(domain: String): Result { - cachedData.remove(domain) - return Result.success(Unit) - } -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt deleted file mode 100644 index e08969d0f9..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetriever.kt +++ /dev/null @@ -1,24 +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.wellknown.test - -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource -import io.element.android.libraries.wellknown.api.WellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult - -class FakeWellknownRetriever( - private val getElementWellKnownResult: (String, EnterpriseRemoteConfigSource) -> WellknownRetrieverResult = { _, _ -> - WellknownRetrieverResult.NotFound - }, -) : WellknownRetriever { - override suspend fun getElementWellKnown(host: String, source: EnterpriseRemoteConfigSource): WellknownRetrieverResult { - return getElementWellKnownResult(host, source) - } -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetrieverFactory.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetrieverFactory.kt deleted file mode 100644 index 718649dd5d..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeWellknownRetrieverFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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.wellknown.test - -import io.element.android.libraries.matrix.api.UrlContentFetcher -import io.element.android.libraries.wellknown.api.WellknownRetriever - -class FakeWellknownRetrieverFactory( - private val wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), -) : WellknownRetriever.Factory { - override fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever = wellknownRetriever -} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt deleted file mode 100644 index 36d9f7bc82..0000000000 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt +++ /dev/null @@ -1,55 +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.wellknown.test - -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase -import io.element.android.libraries.wellknown.api.ElementWellKnown -import io.element.android.libraries.wellknown.api.MapTilerConfig - -fun anElementWellKnown( - registrationHelperUrl: String? = null, - enforceElementPro: Boolean? = null, - rageshakeUrl: String? = null, - brandColor: String? = null, - notificationSound: String? = null, - identityProviderAppScheme: String? = null, - customRecoveryPassphrase: CustomRecoveryPassphrase? = null, - contentScannerUrl: String? = null, - forceDisableE2EE: Boolean? = null, - mapTilerConfig: MapTilerConfig? = null, -) = ElementWellKnown( - registrationHelperUrl = registrationHelperUrl, - enforceElementPro = enforceElementPro, - rageshakeUrl = rageshakeUrl, - brandColor = brandColor, - notificationSound = notificationSound, - identityProviderAppScheme = identityProviderAppScheme, - customRecoveryPassphrase = customRecoveryPassphrase, - contentScannerUrl = contentScannerUrl, - forceDisableE2EE = forceDisableE2EE, - mapTilerConfig = mapTilerConfig, -) - -fun aCustomRecoveryPassphrase( - minCharacterCount: Int = 8, -) = CustomRecoveryPassphrase( - minCharacterCount = minCharacterCount, -) - -fun aMapTilerConfig( - apiKey: String = "test_api_key", - baseUrl: String? = null, - lightStyleId: String? = null, - darkStyleId: String? = null, -) = MapTilerConfig( - apiKey = apiKey, - baseUrl = baseUrl, - lightStyleId = lightStyleId, - darkStyleId = darkStyleId, -) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 1c77bea7cf..89ad4a63fc 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 8 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 1 +private const val versionReleaseNumber = 2 object Versions { /** diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt index 83236f1a5a..4160efeb78 100644 --- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt +++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt @@ -11,18 +11,14 @@ package config object BuildTimeConfig { const val APPLICATION_ID = "io.element.android.x" const val APPLICATION_NAME = "Element X" - const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c" - const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c" - const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c" - val METADATA_HOST_REVERSED: String? = null + val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val URL_WEBSITE: String? = null val URL_LOGO: String? = null val URL_COPYRIGHT: String? = null val URL_ACCEPTABLE_USE: String? = null val URL_PRIVACY: String? = null val URL_POLICY: String? = null - val OAUTH_CLIENT_URL_PATH: String? = "apps/android" val SERVICES_MAPTILER_BASE_URL: String? = null val SERVICES_MAPTILER_APIKEY: String? = null val SERVICES_MAPTILER_LIGHT_MAPID: String? = null @@ -33,7 +29,9 @@ object BuildTimeConfig { val SERVICES_SENTRY_DSN_RUST: String? = null val BUG_REPORT_URL: String? = null val BUG_REPORT_APP_NAME: String? = null - - const val PUSH_CONFIG_INCLUDE_FIREBASE = true - const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH = true + const val PUSH_CONFIG_INCLUDE_FIREBASE: Boolean = true + const val PUSH_CONFIG_INCLUDE_UNIFIED_PUSH: Boolean = true + val PUSHER_APP_ID_RELEASE: String? = null + val PUSHER_APP_ID_DEBUG: String? = null + val PUSHER_APP_ID_NIGHTLY: String? = null } diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt index eb59579d79..01f4329111 100644 --- a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogEndpointConfigProvider.kt @@ -9,7 +9,6 @@ package io.element.android.services.analyticsproviders.posthog import dev.zacsweers.metro.Inject -import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.core.extensions.isElement import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType @@ -17,10 +16,9 @@ import io.element.android.libraries.core.meta.BuildType @Inject class PosthogEndpointConfigProvider( private val buildMeta: BuildMeta, - private val enterpriseService: EnterpriseService, ) { fun provide(): PosthogEndpointConfig? { - return if (enterpriseService.isEnterpriseBuild) { + return if (buildMeta.isEnterpriseBuild) { PosthogEndpointConfig( host = BuildConfig.POSTHOG_HOST, apiKey = BuildConfig.POSTHOG_APIKEY, diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png deleted file mode 100644 index c9bf9e3c1c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0990301d92d4862054ba5e54103dac359df37997242498766854d7bb13431657 -size 6068 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png deleted file mode 100644 index fb829c4873..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3022b2b813d9a3f77b0ba72706f0fb870a1c3f7b4a8efc90e611b66b6e9bdf8e -size 5926 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png deleted file mode 100644 index 23858ce575..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c7544e2566517e8692a1cccd4607bc2ab0f2767bbe9e5b39eb439d7695ad2ce5 -size 46905 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png deleted file mode 100644 index 9b505f3cf9..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b5dbf45467305e83f302fd626a2c0adcd27eac6aeea8b7a83efdb6e728d974da -size 44703 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png deleted file mode 100644 index 815986cbc3..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:41907de78c86fca804af2af7dd351db6a1ab87515e71bb906ffa2219ad4f5879 -size 45880 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png deleted file mode 100644 index f08662715a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:62581b532695d10a42432dfcb37249c28c2bd617ecb49d7f3a8224855b78ae55 -size 43600 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png index 721b1f8389..caa1dc1cbb 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9fb394bf8c063e5b4457fc75c171849b1ae7f435941e0d86d09298a846595861 -size 35160 +oid sha256:da11677aad93fcf9461c62a0fb9a1f384950dc69f0666914da5785265d56a4dd +size 35877 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png index 3d04fae696..cb6f870735 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4626f30754044cee4438c44f8972f1b643342aea9c7b38f3650874da8c7e7614 -size 37303 +oid sha256:99d28d3547d50e9ae23b012521ef5f8a157aa4b3a8a98b3e138847652f8cacff +size 35761 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png index c0d9a71d1d..d9eaefae3c 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ccaed5beab96b32c7ba783136d1fe8483697de44d0ed7c7555cbef9dd3708e66 -size 38953 +oid sha256:84954d900742e981811f1471fbe175f57c927ffc4da9e801d5e5b48f073dc13a +size 39610 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png new file mode 100644 index 0000000000..aa987474a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a524b0538ad4204ea66c46c139035671f55d874c45d46476fb885c15879b93f2 +size 31806 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png index 0127885711..0b0b5f123c 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ae87930bb1123d432f7f4e2792ad1e372ec383f968ad335b4f2f069b8faa307 -size 34294 +oid sha256:bb5607ed2e0986d5035120a2e6276ff33e65a7bafa5325a0c0c22f1975b69c2b +size 34986 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png index b15dcf0142..4da08d87a3 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:787b3708bfc0bebd19cde31589e175ee0ea593c4bd1461b9593e9705947e6ffc -size 36280 +oid sha256:80c7e798346755382f913f72da9b18fc200867280e6f33adde3622a964f7d2d7 +size 34883 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png index b9046df90d..3d6c836738 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e8d1a1013e5eaed74889193e4c6551ae5afa96a1d10fa3d32de3dc96a0c1ae13 -size 36582 +oid sha256:cbe35c9bb9ec509b64a2ce27afa879b8904269ae8f9afbd5ac8a91098d7fccb3 +size 38777 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png new file mode 100644 index 0000000000..bada75332c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efa31bac320466c41fbf33b462418716c1f4fb6def584b38834320d213d2c819 +size 29777 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png deleted file mode 100644 index 34cf030cba..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:09bf68ea40f2b1c109b015b8e9e9c767931ef70936a956652ac649b8e5a2561e -size 13491 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png deleted file mode 100644 index 67186daea6..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:323218a1ce6e4afaf4632fed871cbc6e17ed6686243523aeb9da0d81a7825d31 -size 13602 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png deleted file mode 100644 index c0cc08611c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:193e7af1adc71d5e1133694e234671ebb6fefe3766c9891733c3ae46a999b1cd -size 13655 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png deleted file mode 100644 index 26e32e2174..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cb76a05a00c3e41c7edaa853db6d3773fddfb23c750eea4f45d4f4b1acfe5447 -size 15466 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png deleted file mode 100644 index da53968272..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a153cb8df9aadaa9cee0fef7f140e43f33851f18815674edc9a9682a7c601d2 -size 12942 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png deleted file mode 100644 index 5df3ef308b..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cba9108df3dae3f9968825e13528087fdf5d8b70c03faabbf0b06e276392a7d7 -size 13048 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png deleted file mode 100644 index 91a5e6418a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:79cd361b36c8be418871a8cf6773bcc8f3626fc8d09012340f6d245ea6b0574e -size 12967 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png deleted file mode 100644 index c6b38f80b0..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.createaccount_CreateAccountView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9063e4d7b900c397dacd964c25ce42865e292ea2a5a41f37cb7dfabd86f9246f -size 14142 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png deleted file mode 100644 index 86e3a6a0c1..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:64ca7f84a90416cac5161e379079edb3edadf1f82240214974a3216cd5d41090 -size 23418 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png deleted file mode 100644 index 583a7beb59..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bd5189b1079f9011162ff7ba2c9211fbfff543db892b86ea3b57ec78495d2019 -size 50344 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png deleted file mode 100644 index fd9fce9c5c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5122ee1af4ff4ddc5e4c766f992e9833e4c1329bc634e82b0e52743326328447 -size 22757 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png deleted file mode 100644 index 1be9bd5d99..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96742102f57f2e58027e3402ea01c91ee1eab6010dc46ef8ff8ee3175264a628 -size 49259 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png index f47e74ab7b..78a758b1e8 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5823ae9a382421609200d569799505706180b94c6923f315caa81f8767ac58f8 -size 131340 +oid sha256:996251b2e0e0a28ec167ec137f913ed5bb6f21b443bbf8151bc3203ad853b667 +size 131374 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png index 96215547bb..bae0e74d1e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1622f9765fda65985a369d6958f7a5993696dd629c0c1013c0a1647cfb7b562 -size 50109 +oid sha256:25fdfa4b65263c02e10c6ddea16a3210a84722a742a66cd2d91b8b3f61968466 +size 49924 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png index 70d41498a8..389ae383c8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a94de003f65d2fb30ab42e4ac60802707abf1358b0defed7598a3aad69992f40 -size 48136 +oid sha256:dc99c7505aa48b7ccdbae1de6daec75c12b9293172c804ef3cf027f6b46c45ea +size 47883 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png index c17cbfdb66..45f5ce2bde 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdab4bea33337547250355c29991f45c118e8c2c05abdf69b1824475feced5d6 -size 51241 +oid sha256:c446e3b322a1eb8634f2f643819672cfb626ca9e76c7e1fb5a10fc03a1ae7824 +size 51031 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png index ef9c49d6c5..ff238fd979 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerAddCaption_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f92c594febc463911c8418deeff3b94e5a8ce4171461d812a0f4f527ab0cf8e -size 49245 +oid sha256:86260ffd841034c31327df736e0e127271f875a6cf383c688b7ea8c3a9d1dbd4 +size 48943 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png index 9ba3d99527..cebea0c88b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81d8acdb1158c3e5c19b5a602e3b9afe7643f1bb77d43f51d3f5c4327478a76a -size 50282 +oid sha256:9669ba8ab19e7f7f1322e973e6f39855113b45ab5f6b26e7b5230a2fe9ec1d68 +size 50067 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png index 969e5b04bf..50cd50736a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditCaption_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c225ad12c5a32e2392606f9afacc6dbc37478a141d5485e4942012c6b22ad052 -size 48046 +oid sha256:f1f66076e3b06df1471f367bed57f81d15272e3600eebc51cd131794e29d5dfb +size 47767 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png index a0192f0890..c4b21b86af 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cfb1d8b7828692d5c318206770f9484822a1749b3e80a618b502811cf512d76 -size 60489 +oid sha256:ffe11074abe52ccd1c34f687f780c5185be370b2b92867ee0b686bad85f28890 +size 60210 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png index c745fa256a..839c4a88ba 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca67409e4af247b43d8594d6351419cd12894485357596aff7b26745e2df009a -size 57687 +oid sha256:e9914f3ffd70dd83013ff6059539bda348ae64fc39a2419d621f8d198f6471cc +size 57380 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png index 635e14be9e..f47016a59f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b3a91d104a5949dde83ac38b3127afa342816c631a1e29d78e2ada1bcc906bb -size 50150 +oid sha256:895ba0487ed933f4c8eddc99fe008032fe01454771ccf2987905aec357f3d9c0 +size 49972 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png index 573b6123ed..adfcb6a616 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerEdit_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69c281829fbca15261f196d2134f933b789ff444cd7bebc88d05cdbb7553357f -size 48012 +oid sha256:b5a435c5aa78ffda52d96d611ab869d006e6c95e8aaf4cd005d99128b4f60b1c +size 47736 diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index fcb3669a59..7eae1a5a78 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,4 +1,4 @@ -package: name='io.element.android.x' versionCode='202608010' versionName='26.08.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +package: name='io.element.android.x' versionCode='202608020' versionName='26.08.2' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' diff --git a/tools/release/fix-pg-map-id.py b/tools/release/fix-pg-map-id.py deleted file mode 100644 index c37fec2528..0000000000 --- a/tools/release/fix-pg-map-id.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/python3 -# encoding: utf-8 -# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman -# SPDX-License-Identifier: GPL-3.0-or-later. - -import hashlib -import os -import re -import struct -import zipfile -import zlib - -from binascii import hexlify -from typing import Any, Dict, Match, Tuple - -DEX_MAGIC = b"dex\n" -DEX_MAGIC_RE = re.compile(rb"dex\n(\d{3})\x00") - -PROF_MAGIC = b"pro\x00" -PROF_010_P = b"010\x00" - -CLASSES_DEX_RE = re.compile(r"classes\d*\.dex") -ASSET_PROF = "assets/dexopt/baseline.prof" - -PG_MAP_ID_RE = re.compile(rb'(~~R8{"backend":"dex".*?"pg-map-id":")([0-9a-f]{7})(")') - -ATTRS = ("compress_type", "create_system", "create_version", "date_time", - "external_attr", "extract_version", "flag_bits") -LEVELS = (9, 6, 4, 1) - - -class Error(RuntimeError): - pass - - -# FIXME: is there a better alternative? -class ReproducibleZipInfo(zipfile.ZipInfo): - """Reproducible ZipInfo hack.""" - - if "_compresslevel" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined] - if "compress_level" not in zipfile.ZipInfo.__slots__: # type: ignore[attr-defined] - raise Error("zipfile.ZipInfo has no ._compresslevel") - - _compresslevel: int - _override: Dict[str, Any] = {} - - def __init__(self, zinfo: zipfile.ZipInfo, **override: Any) -> None: - # pylint: disable=W0231 - if override: - self._override = {**self._override, **override} - for k in self.__slots__: - if hasattr(zinfo, k): - setattr(self, k, getattr(zinfo, k)) - - def __getattribute__(self, name: str) -> Any: - if name != "_override": - try: - return self._override[name] - except KeyError: - pass - return object.__getattribute__(self, name) - - -def fix_pg_map_id(input_dir: str, output_dir: str, map_id: str) -> None: - file_data = {} - for filename in [ASSET_PROF] + sorted(os.listdir(input_dir)): - if re.fullmatch(CLASSES_DEX_RE, filename) or filename == ASSET_PROF: - print(f"reading {filename!r}...") - with open(os.path.join(input_dir, *filename.split("/")), "rb") as fh: - file_data[filename] = fh.read() - _fix_pg_map_id(file_data, map_id) - for filename, data in file_data.items(): - print(f"writing {filename!r}...") - if "/" in filename: - os.makedirs(os.path.join(output_dir, *filename.split("/")[:-1]), exist_ok=True) - with open(os.path.join(output_dir, *filename.split("/")), "wb") as fh: - fh.write(data) - - -def fix_pg_map_id_apk(input_apk: str, output_apk: str, map_id: str) -> None: - with open(input_apk, "rb") as fh_raw: - with zipfile.ZipFile(input_apk) as zf_in: - with zipfile.ZipFile(output_apk, "w") as zf_out: - file_data = {} - for info in zf_in.infolist(): - if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF: - print(f"reading {info.filename!r}...") - file_data[info.filename] = zf_in.read(info) - _fix_pg_map_id(file_data, map_id) - for info in zf_in.infolist(): - attrs = {attr: getattr(info, attr) for attr in ATTRS} - zinfo = ReproducibleZipInfo(info, **attrs) - if info.compress_type == 8: - fh_raw.seek(info.header_offset) - n, m = struct.unpack(" 0: - ccrc = zlib.crc32(fh_raw.read(min(size, 4096)), ccrc) - size -= 4096 - with zf_in.open(info) as fh_in: - comps = {lvl: zlib.compressobj(lvl, 8, -15) for lvl in LEVELS} - ccrcs = {lvl: 0 for lvl in LEVELS} - while True: - data = fh_in.read(4096) - if not data: - break - for lvl in LEVELS: - ccrcs[lvl] = zlib.crc32(comps[lvl].compress(data), ccrcs[lvl]) - for lvl in LEVELS: - if ccrc == zlib.crc32(comps[lvl].flush(), ccrcs[lvl]): - zinfo._compresslevel = lvl - break - else: - raise Error(f"Unable to determine compresslevel for {info.filename!r}") - elif info.compress_type != 0: - raise Error(f"Unsupported compress_type {info.compress_type}") - if re.fullmatch(CLASSES_DEX_RE, info.filename) or info.filename == ASSET_PROF: - print(f"writing {info.filename!r}...") - zf_out.writestr(zinfo, file_data[info.filename]) - else: - with zf_in.open(info) as fh_in: - with zf_out.open(zinfo, "w") as fh_out: - while True: - data = fh_in.read(4096) - if not data: - break - fh_out.write(data) - - -def _fix_pg_map_id(file_data: Dict[str, bytes], map_id: str) -> None: - crcs = {} - for filename in file_data: - if re.fullmatch(CLASSES_DEX_RE, filename): - print(f"fixing {filename!r}...") - data = _fix_dex_id_checksum(file_data[filename], map_id.encode()) - file_data[filename] = data - crcs[filename] = zlib.crc32(data) - if ASSET_PROF in file_data: - print(f"fixing {ASSET_PROF!r}...") - file_data[ASSET_PROF] = _fix_prof_checksum(file_data[ASSET_PROF], crcs) - - -def _fix_dex_id_checksum(data: bytes, map_id: bytes) -> bytes: - def repl(m: Match[bytes]) -> bytes: - print(f"fixing pg-map-id: {m.group(2)!r} -> {map_id!r}") - return m.group(1) + map_id + m.group(3) - - magic = data[:8] - if magic[:4] != DEX_MAGIC or not DEX_MAGIC_RE.fullmatch(magic): - raise Error(f"Unsupported magic {magic!r}") - print(f"dex version={int(magic[4:7]):03d}") - checksum, signature = struct.unpack(" {hexlify(fixed_sig).decode()}") - fixed_data = fixed_sig + fixed_data - fixed_checksum = zlib.adler32(fixed_data) - print(f"fixing checksum: 0x{checksum:x} -> 0x{fixed_checksum:x}") - return magic + int.to_bytes(fixed_checksum, 4, "little") + fixed_data - - -def _fix_prof_checksum(data: bytes, crcs: Dict[str, int]) -> bytes: - magic, data = _split(data, 4) - version, data = _split(data, 4) - if magic == PROF_MAGIC: - if version == PROF_010_P: - print("prof version=010 P") - return PROF_MAGIC + PROF_010_P + _fix_prof_010_p_checksum(data, crcs) - else: - raise Error(f"Unsupported prof version {version!r}") - else: - raise Error(f"Unsupported magic {magic!r}") - - -def _fix_prof_010_p_checksum(data: bytes, crcs: Dict[str, int]) -> bytes: - num_dex_files, uncompressed_data_size, compressed_data_size, data = _unpack(" 0x{fixed_checksum:x}") - dex_data_headers.append(struct.pack( - " Any: - assert all(c in " Tuple[bytes, bytes]: - return data[:size], data[size:] - - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(prog="fix-pg-map-id.py") - parser.add_argument("input_dir_or_apk", metavar="INPUT_DIR_OR_APK") - parser.add_argument("output_dir_or_apk", metavar="OUTPUT_DIR_OR_APK") - parser.add_argument("pg_map_id", metavar="PG_MAP_ID") - args = parser.parse_args() - if os.path.isdir(args.input_dir_or_apk): - fix_pg_map_id(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id) - else: - fix_pg_map_id_apk(args.input_dir_or_apk, args.output_dir_or_apk, args.pg_map_id) - -# vim: set tw=80 sw=4 sts=4 et fdm=marker : diff --git a/tools/release/inplace-fix.py b/tools/release/inplace-fix.py deleted file mode 100644 index a791e69140..0000000000 --- a/tools/release/inplace-fix.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/python3 -# encoding: utf-8 -# SPDX-FileCopyrightText: 2024 FC (Fay) Stegerman -# SPDX-License-Identifier: GPL-3.0-or-later. - -import argparse -import os -import shutil -import subprocess -import sys -import tempfile - -from typing import Optional, Tuple - -COMMANDS = ( - "fix-compresslevel", - "fix-files", - "fix-newlines", - "fix-pg-map-id", - "rm-files", - "sort-apk", - "sort-baseline", -) - -BUILD_TOOLS_WITH_BROKEN_ZIPALIGN = ("31.0.0", "32.0.0") -BUILD_TOOLS_WITH_PAGE_SIZE_FROM = "35.0.0-rc1" -SDK_ENV = ("ANDROID_HOME", "ANDROID_SDK", "ANDROID_SDK_ROOT") - - -def _zipalign_cmd(page_align: bool, page_size: Optional[int]) -> Tuple[str, ...]: - if page_align: - if page_size is not None: - return ("zipalign", "-P", str(page_size), "4") - return ("zipalign", "-p", "4") - return ("zipalign", "4") - - -ZIPALIGN = _zipalign_cmd(page_align=False, page_size=None) -ZIPALIGN_P = _zipalign_cmd(page_align=True, page_size=None) - - -class Error(RuntimeError): - pass - - -def inplace_fix(command: str, input_file: str, *args: str, - zipalign: bool = False, page_align: bool = False, - page_size: Optional[int] = None, internal: bool = False) -> None: - if command not in COMMANDS: - raise Error(f"Unknown command {command}") - exe, script = _script_cmd(command) - ext = os.path.splitext(input_file)[1] - with tempfile.TemporaryDirectory() as tdir: - fixed = os.path.join(tdir, "fixed" + ext) - run_command(exe, script, input_file, fixed, *args, trim=2) - if zipalign: - aligned = os.path.join(tdir, "aligned" + ext) - zac = zipalign_cmd(page_align=page_align, page_size=page_size, internal=internal) - run_command(*zac, fixed, aligned, trim=2) - print(f"[MOVE] {aligned} to {input_file}") - shutil.move(aligned, input_file) - else: - print(f"[MOVE] {fixed} to {input_file}") - shutil.move(fixed, input_file) - - -def zipalign_cmd(page_align: bool = False, page_size: Optional[int] = None, - internal: bool = False) -> Tuple[str, ...]: - """ - Find zipalign command using $PATH or $ANDROID_HOME etc. - - >>> zipalign_cmd() - ('zipalign', '4') - >>> zipalign_cmd(page_align=True) - ('zipalign', '-p', '4') - >>> zipalign_cmd(page_align=True, page_size=16) - ('zipalign', '-P', '16', '4') - >>> cmd = zipalign_cmd(page_align=True, page_size=16, internal=True) - >>> [x.split("/")[-1] for x in cmd] - ['python3', 'zipalign.py', '-P', '16', '4'] - >>> os.environ["PATH"] = "" - >>> for k in SDK_ENV: - ... os.environ[k] = "" - >>> cmd = zipalign_cmd() - >>> [x.split("/")[-1] for x in cmd] - ['python3', 'zipalign.py', '4'] - >>> os.environ["ANDROID_HOME"] = "test/fake-sdk" - >>> zipalign_cmd() - [SKIP BROKEN] 31.0.0 - [FOUND] test/fake-sdk/build-tools/30.0.3/zipalign - ('test/fake-sdk/build-tools/30.0.3/zipalign', '4') - >>> cmd = zipalign_cmd(page_align=True, page_size=16) - [SKIP TOO OLD] 31.0.0 - [SKIP TOO OLD] 30.0.3 - [SKIP TOO OLD] 26.0.2 - >>> [x.split("/")[-1] for x in cmd] - ['python3', 'zipalign.py', '-P', '16', '4'] - >>> os.environ["ANDROID_HOME"] = "test/fake-sdk-2" - >>> zipalign_cmd(page_align=True, page_size=16) - [FOUND] test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign - ('test/fake-sdk-2/build-tools/35.0.0-rc1/zipalign', '-P', '16', '4') - - """ - cmd, *args = _zipalign_cmd(page_align, page_size) - if not internal: - if shutil.which(cmd): - return (cmd, *args) - for k in SDK_ENV: - if home := os.environ.get(k): - tools = os.path.join(home, "build-tools") - if os.path.exists(tools): - for vsn in sorted(os.listdir(tools), key=_vsn, reverse=True): - if page_size and _vsn(vsn) < _vsn(BUILD_TOOLS_WITH_PAGE_SIZE_FROM): - print(f"[SKIP TOO OLD] {vsn}") - continue - for s in BUILD_TOOLS_WITH_BROKEN_ZIPALIGN: - if vsn.startswith(s): - print(f"[SKIP BROKEN] {vsn}") - break - else: - c = os.path.join(tools, vsn, cmd) - if shutil.which(c): - print(f"[FOUND] {c}") - return (c, *args) - return (*_script_cmd(cmd), *args) - - -def _vsn(v: str) -> Tuple[int, ...]: - """ - >>> vs = "31.0.0 32.1.0-rc1 34.0.0-rc3 34.0.0 35.0.0-rc1".split() - >>> for v in sorted(vs, key=_vsn, reverse=True): - ... (_vsn(v), v) - ((35, 0, 0, 0, 1), '35.0.0-rc1') - ((34, 0, 0, 1, 0), '34.0.0') - ((34, 0, 0, 0, 3), '34.0.0-rc3') - ((32, 1, 0, 0, 1), '32.1.0-rc1') - ((31, 0, 0, 1, 0), '31.0.0') - """ - if "-rc" in v: - v = v.replace("-rc", ".0.", 1) - else: - v = v + ".1.0" - return tuple(int(x) if x.isdigit() else -1 for x in v.split(".")) - - -def _script_cmd(command: str) -> Tuple[str, str]: - script_dir = os.path.dirname(__file__) - for cmd in (command, command.replace("-", "_")): - script = os.path.join(script_dir, cmd + ".py") - if os.path.exists(script): - break - else: - raise Error(f"Script for {command} not found") - exe = sys.executable or "python3" - return exe, script - - -def run_command(*args: str, trim: int = 1) -> None: - targs = tuple(os.path.basename(a) for a in args[:trim]) + args[trim:] - print(f"[RUN] {' '.join(targs)}") - try: - subprocess.run(args, check=True) - except subprocess.CalledProcessError as e: - raise Error(f"{args[0]} command failed") from e - except FileNotFoundError as e: - raise Error(f"{args[0]} command not found") from e - - -def main() -> None: - prog = os.path.basename(sys.argv[0]) - usage = (f"{prog} [-h] [--zipalign] [--page-align] [--page-size N] [--internal]\n" - f"{len('usage: ' + prog) * ' '} COMMAND INPUT_FILE [...]") - epilog = f"Commands: {', '.join(COMMANDS)}." - parser = argparse.ArgumentParser(usage=usage, epilog=epilog) - parser.add_argument("--zipalign", action="store_true", - help="run zipalign after COMMAND") - parser.add_argument("--page-align", action="store_true", - help="run zipalign w/ -p option (implies --zipalign)") - parser.add_argument("--page-size", metavar="N", type=int, - help="run zipalign w/ -P N option (implies --page-align)") - parser.add_argument("--internal", action="store_true", - help="use zipalign.py instead of searching $PATH/$ANDROID_HOME/etc.") - parser.add_argument("command", metavar="COMMAND") - parser.add_argument("input_file", metavar="INPUT_FILE") - args, rest = parser.parse_known_args() - try: - inplace_fix(args.command, args.input_file, *rest, - zipalign=bool(args.zipalign or args.page_align or args.page_size), - page_align=bool(args.page_align or args.page_size), - page_size=args.page_size, internal=args.internal) - except Error as e: - print(f"Error: {e}.", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() - -# vim: set tw=80 sw=4 sts=4 et fdm=marker : diff --git a/tools/release/release.sh b/tools/release/release.sh index 83ccc6d258..392074208a 100755 --- a/tools/release/release.sh +++ b/tools/release/release.sh @@ -225,15 +225,6 @@ printf "Unzipping the F-Droid artifact...\n" fdroidTargetPath="${targetPath}/fdroid" unzip "${targetPath}"/elementx-app-fdroid-apks-unsigned.zip -d "${fdroidTargetPath}" -printf "\n================================================================================\n" -printf "Patching the FDroid APKs using inplace-fix.py...\n" - -inplaceFixScript="./tools/release/inplace-fix.py" -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk '0000000' -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk '0000000' -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86-release.apk '0000000' -python3 "${inplaceFixScript}" --page-size 16 fix-pg-map-id "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk '0000000' - printf "\n================================================================================\n" printf "Signing the FDroid APKs...\n"