Merge branch 'release/26.06.0'

This commit is contained in:
Jorge Martín
2026-06-04 11:09:39 +02:00
766 changed files with 12837 additions and 2738 deletions
+1
View File
@@ -52,6 +52,7 @@ Uncomment this markdown table below and edit the last line `|||`:
<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->
- [ ] I am aware of the [etiquette](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#etiquette).
- This PR was made with the help of AI:
- [ ] Yes. In this case, please request a review by Copilot.
- [ ] No.
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]'
uses: tspascoal/get-user-teams-membership@818140d631d5f29f26b151afbe4179f87d9ceb5e # v4.0.1
uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
only-labels: "X-Needs-Info"
days-before-issue-stale: 30
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
# https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+8 -5
View File
@@ -44,11 +44,14 @@ PRs must meet these rules.
## Pull Request Guidelines
- Use sentence-style commit/PR messages (no conventional commits).
- Apply exactly **one** `PR-` label for changelog categorization.
- PR title = changelog entry — make it descriptive; no "Fixes #…" prefixes.
- Include screenshots or screen recordings for any UI changes.
- Keep PRs focused; split changes over 1000 lines.
- Sentence-style titles (no conventional commits).
- Exactly one `pr-` label (see `.github/release.yml`).
- Title = changelog entry — descriptive, no "Fixes #…".
- Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
- Screenshots/videos for visual changes.
- 500 additions max — split large changes.
- Commits need a title and description; no tiny or massive commits.
- No history rewrites.
---
+39
View File
@@ -1,3 +1,42 @@
Changes in Element X v26.05.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.05.2 -->
## What's Changed
### ✨ Features
* Remove SignInWithClassic FeatureFlag to enable the feature. by @bmarty in https://github.com/element-hq/element-x-android/pull/6698
* Create a new room when inviting people in a DM by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6756
* Remove LiveLocationSharing feature flag by @ganfra in https://github.com/element-hq/element-x-android/pull/6811
### 🙌 Improvements
* Disable biometric unlock when we disable pin code unlock by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6781
### 🐛 Bugfixes
* Fix room list duplicate-detection telemetry crashing before it can report by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/6791
* Only load full media on media viewer when it's the visible item by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6794
* Attempt to fix room list item duplicates at midnight by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6793
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6798
### 🧱 Build
* Fix Maestro again after changes to the invite flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6796
* Renovate: Keep Guava on the Android variant and ignore jre-only upgrades by @bmarty in https://github.com/element-hq/element-x-android/pull/6776
### Dependency upgrades
* Update dependency androidx.compose:compose-bom to v2026.05.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6784
* Update dependency io.sentry:sentry-android to v8.41.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6787
* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6790
* Update camera to v1.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6783
* Update dependency androidx.webkit:webkit to v1.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6786
* Update dependency com.google.firebase:firebase-bom to v34.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6789
* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6805
### Others
* Add MIDI playback by @cizra in https://github.com/element-hq/element-x-android/pull/6770
* Show error message when using "Sign in with QR code" with a QR from a device that is also not signed in by @hughns in https://github.com/element-hq/element-x-android/pull/6802
## New Contributors
* @jennaharris7 made their first contribution in https://github.com/element-hq/element-x-android/pull/6791
* @cizra made their first contribution in https://github.com/element-hq/element-x-android/pull/6770
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.05.1...v26.05.2
Changes in Element X v26.05.1
=============================
+23 -2
View File
@@ -6,7 +6,8 @@
* [I want to help translating Element](#i-want-to-help-translating-element)
* [I want to fix a bug](#i-want-to-fix-a-bug)
* [I want to add a new feature or enhancement](#i-want-to-add-a-new-feature-or-enhancement)
* [Developer onboarding](#developer-onboarding)
* [Etiquette](#etiquette)
* [Developer onboarding](#developer-onboarding)
* [Submitting the PRs](#submitting-the-prs)
* [Android Studio settings](#android-studio-settings)
* [Compilation](#compilation)
@@ -63,7 +64,27 @@ Once we know that you want to contribute and have confirmed that the new feature
Only once all of the above is met should you open a PR with your proposed changes.
## Developer onboarding
### Etiquette
* As stated above all significant changes should be communicated through an issue before opening a PR
* We are happy to receive contributions but features always require maintenance, so depending on the change we might not be willing to accept it
* We are also fine with AI led contributions within reasonable bounds
* You are completely responsible for the quality of the PR
* If the PR doesn't show minimal effort (code does not compile, code does not work as expected, etc.) on your part it will be closed
* Please write the description yourself, we don't have the bandwidth to read LLM essays. The code needs to speak for itself.
* We use git for version control and GitHub for reviews, so in order to make everybody's life easier please:
* Keep the existing pull request template
* Don't submit large PRs, especially if not previously talked about. Anything above 200 lines is large (excluding generated code e.g. tests, translations, mocks)
* Please don't open unfinished PRs and expect us to fill in the details
* If you would like our opinion/direction on unfinished code please link your branch or idea in the ticket
* Please limit the number of commits in a single PR. We are perfectly happy with splitting work across multiple sessions as long as they're logically independant and show promise of progress (ideally expressed through a ticket)
* Each and every commit should stand on its own, clearly explaining what it does and why
* Once a PR goes into review please don't rewrite the history unless agreed so with the reviewer.
* Tweaks and fixes following review can be directly committed (to be interactively rebased later) or as fixups
*The reviewer's response time will generally match yours. Switching contexts is very hard so please act accordingly. You are responsible for making the reviewers job enjoyable!*
### Developer onboarding
For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
+8 -1
View File
@@ -11,7 +11,14 @@
<item name="windowSplashScreenBackground">@color/splashscreen_bg_dark</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item>
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
<item name="android:windowBackground">@color/splashscreen_bg_dark</item>
<item name="android:statusBarColor">@color/splashscreen_bg_dark</item>
<item name="android:navigationBarColor">@color/splashscreen_bg_dark</item>
</style>
<style name="Theme.ElementX" parent="Theme.Material3.Dark.NoActionBar" />
<style name="Theme.ElementX" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:windowBackground">@color/splashscreen_bg_dark</item>
<item name="android:statusBarColor">@color/splashscreen_bg_dark</item>
<item name="android:navigationBarColor">@color/splashscreen_bg_dark</item>
</style>
</resources>
+6
View File
@@ -10,8 +10,14 @@
<item name="windowSplashScreenBackground">@color/splashscreen_bg_light</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item>
<item name="postSplashScreenTheme">@style/Theme.ElementX</item>
<item name="android:windowBackground">@color/splashscreen_bg_light</item>
<item name="android:statusBarColor">@color/splashscreen_bg_light</item>
<item name="android:navigationBarColor">@color/splashscreen_bg_light</item>
</style>
<style name="Theme.ElementX" parent="Theme.Material3.Light.NoActionBar">
<item name="android:forceDarkAllowed" tools:targetApi="q">false</item>
<item name="android:windowBackground">@color/splashscreen_bg_light</item>
<item name="android:statusBarColor">@color/splashscreen_bg_light</item>
<item name="android:navigationBarColor">@color/splashscreen_bg_light</item>
</style>
</resources>
+1
View File
@@ -7,4 +7,5 @@
-->
<paths>
<cache-path name="cache" path="." />
<files-path name="notification_sounds" path="notification_sounds/" />
</paths>
+1
View File
@@ -2,6 +2,7 @@
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="be"/>
<locale android:name="bg"/>
<locale android:name="ca"/>
<locale android:name="cs"/>
<locale android:name="cy"/>
<locale android:name="da"/>
@@ -382,6 +382,10 @@ class LoggedInFlowNode(
}
is NavTarget.Room -> {
val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onDone() {
backstack.pop()
}
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) {
lifecycleScope.launch {
attachRoom(roomIdOrAlias = roomId.toRoomIdOrAlias(), serverNames = serverNames, clearBackstack = clearBackStack)
@@ -82,6 +82,7 @@ class JoinedRoomLoadedFlowNode(
plugins = plugins,
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onDone()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean = false)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun navigateToGlobalNotificationSettings()
@@ -142,6 +143,10 @@ class JoinedRoomLoadedFlowNode(
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
val callback = object : RoomDetailsEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
override fun navigateToGlobalNotificationSettings() {
callback.navigateToGlobalNotificationSettings()
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Tanca sessió i actualitza"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ja no admet el protocol antic. Tanca sessió i torna a entrar per continuar utilitzant l\'aplicació."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"El servidor utilitzat ja no admet el protocol antic. Tanca sessió i torna-la a iniciar per continuar utilitzant l\'aplicació."</string>
</resources>
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.tests.testutils.lambda.lambdaError
class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback {
override fun onDone() = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError()
+3 -3
View File
@@ -30,7 +30,7 @@ tasks.register<Delete>("clean").configure {
delete(rootProject.layout.buildDirectory)
}
private val ktLintVersion = the<LibrariesForLibs>().versions.ktlint.get()
private val catalog = the<LibrariesForLibs>()
allprojects {
// Detekt
@@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.5.8")
detektPlugins(catalog.detekt.compose.rules)
detektPlugins(project(":tests:detekt-rules"))
}
@@ -64,7 +64,7 @@ allprojects {
// See https://github.com/JLLeitschuh/ktlint-gradle#configuration
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
version = ktLintVersion
version = catalog.versions.ktlint.get()
android = true
ignoreFailures = false
enableExperimentalRules = true
@@ -0,0 +1,2 @@
Main changes in this version: added image editing before sending and an option for custom notification sounds, improved start times, several other bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"Comparteix dades d\'ús anònimes per ajudar-nos a identificar problemes."</string>
<string name="screen_analytics_settings_read_terms">"Pots llegir tots els nostres termes %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"aquí"</string>
<string name="screen_analytics_settings_share_data">"Comparteix dades analítiques"</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"No registrarem ni elaborarem perfils de cap dada personal"</string>
<string name="screen_analytics_prompt_help_us_improve">"Comparteix dades d\'ús anònimes per ajudar-nos a identificar problemes."</string>
<string name="screen_analytics_prompt_read_terms">"Pots llegir tots els nostres termes %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"aquí"</string>
<string name="screen_analytics_prompt_settings">"Ho pots desactivar en qualsevol moment"</string>
<string name="screen_analytics_prompt_third_party_sharing">"No compartirem les teves dades amb tercers"</string>
<string name="screen_analytics_prompt_title">"Ajuda\'ns a millorar %1$s"</string>
</resources>
@@ -35,8 +35,9 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey()
// Announcement.Fullscreen.Space is disabled, consider it's shown
// For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08)
// Default-status convention: Shown for one-shot intro/feature announcements that should
// NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted
// in by AppMigration08).
val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
@@ -201,12 +201,9 @@ class WebViewAudioManager(
return
}
coroutineScope.launch {
proximitySensorMutex.withLock {
if (proximitySensorWakeLock?.isHeld == true) {
proximitySensorWakeLock?.release()
}
}
// Since this should run when the call is no longer running, it should be OK to not use the mutex here
if (proximitySensorWakeLock?.isHeld == true) {
proximitySensorWakeLock?.release()
}
audioManager.mode = AudioManager.MODE_NORMAL
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Trucada en curs"</string>
<string name="call_foreground_service_message_android">"Toca per tornar a la trucada"</string>
<string name="call_foreground_service_title_android">"☎️ Trucada en curs"</string>
<string name="screen_incoming_call_subtitle_android">"Element Call entrant"</string>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Sala nova"</string>
<string name="screen_create_room_add_people_title">"Convida persones"</string>
<string name="screen_create_room_error_creating_room">"S\'ha produït un error en crear la sala"</string>
<string name="screen_create_room_private_option_description">"Només s\'hi poden unir les persones convidades."</string>
<string name="screen_create_room_public_option_description">"Tothom pot trobar aquesta sala.
Pots canviar-ho en qualsevol moment a la configuració de sala."</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Qualsevol persona pot sol·licitar unir-s\'hi però un administrador o moderador l\'haurà d\'acceptar"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Permet sol·licituds d\'unió"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Tothom pot unir-s\'hi."</string>
<string name="screen_create_room_room_address_section_footer">"És necessària una adreça perquè sigui visible al directori públic."</string>
<string name="screen_create_room_room_address_section_title">"Adreça"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilitat de sala"</string>
<string name="screen_create_room_topic_label">"Tema (opcional)"</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Si us plau, confirma que vols desactivar el teu compte. Aquesta acció no es pot desfer."</string>
<string name="screen_deactivate_account_delete_all_messages">"Elimina tots els meus missatges"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Avís: els futurs usuaris podrien veure converses incompletes."</string>
<string name="screen_deactivate_account_description">"La desactivació del compte és %1$s, implica:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversible"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s el compte (no podràs tornar a iniciar sessió i el teu ID no es podrà reutilitzar)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Desactiva permanentment"</string>
<string name="screen_deactivate_account_list_item_2">"Se t\'eliminarà de totes les sales o xats."</string>
<string name="screen_deactivate_account_list_item_3">"S\'eliminarà la informació del compte del nostre servidor d\'identitat."</string>
<string name="screen_deactivate_account_list_item_4">"Els teus missatges continuaran sent visibles per als usuaris registrats, però no estaran disponibles per a usuaris nous o no registrats si decideixes eliminar-los."</string>
</resources>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Vahvista, että haluat deaktivoida tilisi. Tätä ei voi perua."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Vahvista, että haluat poistaa tilisi. Tätä ei voi perua."</string>
<string name="screen_deactivate_account_delete_all_messages">"Poista kaikki viestini"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Varoitus: Tulevaisuudessa muut voivat nähdä puutteellisia keskusteluja."</string>
<string name="screen_deactivate_account_description">"Tilisi deaktivointia %1$s. Jos teet sen:"</string>
<string name="screen_deactivate_account_description">"Tilisi poistamista %1$s. Jos teet sen:"</string>
<string name="screen_deactivate_account_description_bold_part">"ei voi peruuttaa"</string>
<string name="screen_deactivate_account_list_item_1">"Tilisi %1$s (et voi kirjautua takaisin sisään, eikä tunnustasi voi käyttää uudelleen)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"poistetaan käytöstä pysyvästi"</string>
<string name="screen_deactivate_account_list_item_2">"Sinut poistetaan kaikista keskusteluhuoneista."</string>
<string name="screen_deactivate_account_list_item_3">"Tilitietosi poistetaan identiteettipalvelimeltamme."</string>
<string name="screen_deactivate_account_list_item_4">"Viestisi näkyvät edelleen rekisteröityneille käyttäjille, mutta ne eivät ole uusien tai rekisteröimättömien käyttäjien saatavilla, jos päätät poistaa ne."</string>
<string name="screen_deactivate_account_title">"Deaktivoi tili"</string>
<string name="screen_deactivate_account_title">"Poista tili"</string>
</resources>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Вы уверены, что хотите отключить свою учётную запись? Данное действие необратимо."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Вы уверены, что хотите удалить свою учётную запись? Данное действие необратимо."</string>
<string name="screen_deactivate_account_delete_all_messages">"Удалить все мои сообщения"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Внимание: в будущем пользователи могут видеть неполные переписки."</string>
<string name="screen_deactivate_account_description">"Деактивация вашего аккаунта %1$s и означает следующее:"</string>
<string name="screen_deactivate_account_description">"Удаление вашего аккаунта %1$s, это означает следующее:"</string>
<string name="screen_deactivate_account_description_bold_part">"необратимо"</string>
<string name="screen_deactivate_account_list_item_1">"Ваша учётная запись будет %1$s (вы не сможете войти в неё снова, и другие пользователи не смогут использовать ваше имя пользователя)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Отключить навсегда"</string>
<string name="screen_deactivate_account_list_item_2">"Вы будете удалены из всех чатов."</string>
<string name="screen_deactivate_account_list_item_3">"Данные Вашего аккаунта будут удалены с нашего сервера идентификации."</string>
<string name="screen_deactivate_account_list_item_4">"Ваши сообщения по-прежнему будут видны зарегистрированным пользователям, но не будут доступны новым или незарегистрированным пользователям, если вы решите удалить их."</string>
<string name="screen_deactivate_account_title">"Отключить учётную запись"</string>
<string name="screen_deactivate_account_title">"Удалить аккаунт"</string>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"No pots confirmar-la?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Crea nova clau de recuperació"</string>
<string name="screen_identity_confirmation_subtitle">"Verifica aquest dispositiu per configurar missatges segurs."</string>
<string name="screen_identity_confirmation_title">"Confirma la teva identitat"</string>
<string name="screen_identity_confirmation_use_another_device">"Utilitza un altre dispositiu"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Utilitza clau de recuperació"</string>
<string name="screen_identity_confirmed_subtitle">"Ara pots llegir o enviar missatges de manera segura, i qualsevol persona amb qui xategis també confiarà en aquest dispositiu."</string>
<string name="screen_identity_confirmed_title">"Dispositiu verificat"</string>
<string name="screen_identity_use_another_device">"Utilitza un altre dispositiu"</string>
<string name="screen_identity_waiting_on_other_device">"Esperant un altre dispositiu…"</string>
<string name="screen_notification_optin_subtitle">"Pots canviar la configuració més tard."</string>
<string name="screen_notification_optin_title">"Permet les notificacions i no perdis cap missatge"</string>
</resources>
@@ -10,6 +10,7 @@ package io.element.android.features.home.impl.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -160,7 +161,7 @@ fun HomeTopBar(
}
@Composable
private fun RoomListMenuItems(
private fun RowScope.RoomListMenuItems(
onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
canReportBug: Boolean,
@@ -245,19 +245,25 @@ private fun RoomsViewList(
)
}
}
SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) {
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
// Banner precedence (top-to-bottom): full-screen-intent > battery-optimization >
// new-notification-sound > sound-unavailable. At most one renders at a time.
SecurityBannerState.None -> when {
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> {
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
}
}
} else if (state.batteryOptimizationState.shouldDisplayBanner) {
item {
BatteryOptimizationBanner(state = state.batteryOptimizationState)
state.batteryOptimizationState.shouldDisplayBanner -> {
item {
BatteryOptimizationBanner(state = state.batteryOptimizationState)
}
}
} else if (state.showNewNotificationSoundBanner) {
item {
NewNotificationSoundBanner(
onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
)
state.showNewNotificationSoundBanner -> {
item {
NewNotificationSoundBanner(
onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
)
}
}
}
}
@@ -273,6 +279,7 @@ private fun RoomsViewList(
hideInviteAvatars = hideInvitesAvatars,
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
state.seenRoomInvites.contains(room.roomId),
showUnreadCount = state.showUnreadCount,
onClick = onRoomClick,
eventSink = eventSink,
)
@@ -75,6 +75,7 @@ internal fun RoomSummaryRow(
room: RoomListRoomSummary,
hideInviteAvatars: Boolean,
isInviteSeen: Boolean,
showUnreadCount: Boolean = false,
onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvent) -> Unit,
modifier: Modifier = Modifier,
@@ -127,7 +128,7 @@ internal fun RoomSummaryRow(
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
MessagePreviewAndIndicatorRow(room = room)
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
}
}
RoomSummaryDisplayType.KNOCKED -> {
@@ -273,6 +274,7 @@ private fun InviteSubtitle(
@Composable
private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary,
showUnreadCount: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@@ -360,8 +362,18 @@ private fun MessagePreviewAndIndicatorRow(
}
if (room.hasNewContent) {
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
val count = if (showUnreadCount) {
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
room.numberOfUnreadMessages
} else {
room.numberOfUnreadNotifications
}
} else {
null
}
UnreadIndicatorAtom(
color = tint,
count = count,
contentDescription = contentDescription,
)
}
@@ -36,6 +36,7 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
showNewNotificationSoundBanner: Boolean = false,
showUnreadCount: Boolean = false,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
@@ -43,6 +44,7 @@ internal fun aRoomsContentState(
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState,
summaries = summaries,
@@ -119,7 +119,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsRead())
),
style = ListItemStyle.Primary,
)
} else {
ListItem(
@@ -133,7 +132,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsUnread())
),
style = ListItemStyle.Primary,
)
}
val (textResId, icon) = if (contextMenu.isFavorite) {
@@ -159,7 +157,6 @@ private fun RoomListModalBottomSheetContent(
onClick = {
onFavoriteChange(!contextMenu.isFavorite)
},
style = ListItemStyle.Primary,
)
ListItem(
headlineContent = {
@@ -174,7 +171,6 @@ private fun RoomListModalBottomSheetContent(
CompoundIcons.Settings(),
)
),
style = ListItemStyle.Primary,
)
if (canReportRoom) {
ListItem(
@@ -211,7 +207,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.Delete())
),
style = ListItemStyle.Primary,
)
}
}
@@ -44,6 +44,8 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -90,6 +92,7 @@ class RoomListPresenter(
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val featureFlagService: FeatureFlagService,
) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService
@@ -165,13 +168,17 @@ class RoomListPresenter(
roomListDataSource.updateFilter(allFilters)
}
val canReportRoom by produceState(false) { value = client.canReportRoom() }
val showUnreadCount by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount)
}
val contentState = roomListContentState(
securityBannerDismissed,
showNewNotificationSoundBanner,
showUnreadCount,
)
val canReportRoom by produceState(false) { value = client.canReportRoom() }
return RoomListState(
contextMenu = contextMenu.value,
declineInviteMenu = declineInviteMenu.value,
@@ -226,6 +233,7 @@ class RoomListPresenter(
private fun roomListContentState(
securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean,
showUnreadCount: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
@@ -254,6 +262,7 @@ class RoomListPresenter(
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
@@ -72,6 +72,7 @@ sealed interface RoomListContentState {
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState,
val showNewNotificationSoundBanner: Boolean,
val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>,
) : RoomListContentState
@@ -48,6 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -93,7 +94,7 @@ fun SpaceFiltersView(
SpaceFiltersBottomSheetContent(
filters = state.visibleFilters,
searchQuery = state.searchQuery,
onFilterSelected = { filter ->
onSelectFilter = { filter ->
state.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(filter))
}
)
@@ -105,9 +106,9 @@ fun SpaceFiltersView(
@Composable
private fun SpaceFiltersBottomSheetContent(
filters: List<SpaceServiceFilter>,
filters: ImmutableList<SpaceServiceFilter>,
searchQuery: TextFieldState,
onFilterSelected: (SpaceServiceFilter) -> Unit,
onSelectFilter: (SpaceServiceFilter) -> Unit,
modifier: Modifier = Modifier
) {
Column(
@@ -133,7 +134,7 @@ private fun SpaceFiltersBottomSheetContent(
items(filters) { filter ->
SpaceFilterItem(
filter = filter,
onClick = { onFilterSelected(filter) }
onClick = { onSelectFilter(filter) }
)
}
}
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Desactiva l\'optimització de bateria d\'aquesta aplicació per assegurar-te de rebre totes les notificacions."</string>
<string name="banner_battery_optimization_title_android">"No arriben les notificacions?"</string>
<string name="banner_set_up_recovery_content">"Recupera la teva identitat criptogràfica i l\'historial de missatges amb una clau de recuperació si has perdut l\'accés a tots els teus dispositius existents."</string>
<string name="banner_set_up_recovery_submit">"Configura la recuperació"</string>
<string name="banner_set_up_recovery_title">"Configura la recuperació per protegir el teu compte"</string>
<string name="confirm_recovery_key_banner_message">"Confirma la clau de recuperació per mantenir l\'accés a l\'emmagatzematge de claus i a l\'historial de missatges."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Introdueix clau de recuperació"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Has oblidat la clau de recuperació?"</string>
<string name="confirm_recovery_key_banner_title">"L\'emmagatzematge de claus no està sincronitzat"</string>
<string name="full_screen_intent_banner_message">"Per assegurar que mai et perdis una trucada important, canvia la configuració per permetre les notificacions en pantalla completa quan el telèfon està bloquejat."</string>
<string name="full_screen_intent_banner_title">"Millora l\'experiència de les trucades"</string>
<string name="screen_home_tab_chats">"Xats"</string>
<string name="screen_invites_decline_chat_message">"Segur que vols rebutjar la invitació per unir-te a %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rebutja invitació"</string>
<string name="screen_invites_decline_direct_chat_message">"Segur que vols rebutjar el xat privat amb %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rebutja xat"</string>
<string name="screen_invites_empty_list">"Sense invitacions"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) t\'ha convidat"</string>
<string name="screen_migration_message">"Aquest procés només s\'ha de fer una vegada, gràcies per esperar."</string>
<string name="screen_migration_title">"Configurant compte."</string>
<string name="screen_roomlist_a11y_create_message">"Crea un nou xat o sala"</string>
<string name="screen_roomlist_empty_message">"Comença enviant un missatge a algú."</string>
<string name="screen_roomlist_empty_title">"Encara no hi ha xats."</string>
<string name="screen_roomlist_filter_favourites">"Preferits"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Pots afegir un xat a preferits a la configuració del xat.
De moment, pots desseleccionar els filtres per veure tots els xats."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Encara no tens cap xat preferit"</string>
<string name="screen_roomlist_filter_invites">"Invitacions"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"No tens cap invitació pendent."</string>
<string name="screen_roomlist_filter_low_priority">"Prioritat baixa"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Pots desseleccionar els filtres per veure els altres xats"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Cap xats per a aquesta selecció"</string>
<string name="screen_roomlist_filter_people">"Persones"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Encara no tens cap xat directe"</string>
<string name="screen_roomlist_filter_rooms">"Sales"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Encara no pertanys a cap sala"</string>
<string name="screen_roomlist_filter_unreads">"No llegits"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Enhorabona!
No tens missatges sense llegir!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Sol·licitud d\'unió enviada"</string>
<string name="screen_roomlist_main_space_title">"Xats"</string>
<string name="screen_roomlist_mark_as_read">"Marca com a llegit"</string>
<string name="screen_roomlist_mark_as_unread">"Marca com a no llegit"</string>
<string name="screen_roomlist_tombstoned_room_description">"La sala ha estat actualitzada"</string>
<string name="session_verification_banner_message">"Sembla que estàs utilitzant un dispositiu nou. Verifica\'l amb un altre dispositiu per accedir als teus missatges xifrats."</string>
<string name="session_verification_banner_title">"Verifica que ets tu"</string>
</resources>
@@ -4,7 +4,7 @@
<string name="banner_battery_optimization_submit_android">"禁用优化"</string>
<string name="banner_battery_optimization_title_android">"通知未送达?"</string>
<string name="banner_new_sound_message">"通知提示音已升级:更清晰、更快速、干扰更少。"</string>
<string name="banner_new_sound_title">"我们已更新你的声音"</string>
<string name="banner_new_sound_title">"我们已更新提示音"</string>
<string name="banner_set_up_recovery_content">"你的聊天已被端到端加密自动备份。如果你无法访问所有设备,则需要使用恢复密钥并保留数字身份。"</string>
<string name="banner_set_up_recovery_submit">"获取恢复密钥"</string>
<string name="banner_set_up_recovery_title">"备份聊天"</string>
@@ -36,6 +36,8 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -89,6 +91,7 @@ import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class RoomListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -668,6 +671,7 @@ class RoomListPresenterTest {
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
@@ -696,5 +700,6 @@ class RoomListPresenterTest {
seenInvitesStore = seenInvitesStore,
announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = featureFlagService,
)
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_decline_and_block_block_user_option_description">"No veuràs cap missatge ni invitacions a sales d\'aquest usuari."</string>
<string name="screen_decline_and_block_block_user_option_title">"Bloqueja usuari"</string>
<string name="screen_decline_and_block_report_user_option_description">"Denuncia aquesta sala al teu proveïdor de compte."</string>
<string name="screen_decline_and_block_report_user_reason_placeholder">"Descriu el motiu de la denúncia…"</string>
<string name="screen_decline_and_block_title">"Rebutja i bloqueja"</string>
<string name="screen_invites_decline_chat_message">"Segur que vols rebutjar la invitació per unir-te a %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rebutja invitació"</string>
<string name="screen_invites_decline_direct_chat_message">"Segur que vols rebutjar el xat privat amb %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rebutja xat"</string>
<string name="screen_invites_empty_list">"Sense invitacions"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) t\'ha convidat"</string>
<string name="screen_join_room_decline_and_block_alert_confirmation">"Sí, rebutja i bloqueja"</string>
<string name="screen_join_room_decline_and_block_alert_message">"Segur que vols rebutjar la invitació d\'unió a aquesta sala? Això també evitarà que %1$s et contacti i et convidi a sales."</string>
<string name="screen_join_room_decline_and_block_alert_title">"Rebutja la invitació i bloqueja"</string>
<string name="screen_join_room_decline_and_block_button_title">"Rebutja i bloqueja"</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Ja és membre"</string>
<string name="screen_invite_users_already_invited">"Ja s\'ha convidat"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"On jo jäsen"</string>
<string name="screen_invite_users_already_invited">"On jo kutsuttu"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Sinulla ei ole tällä hetkellä keskusteluja näiden yhteystietojen kanssa. Vahvista kutsusi heille tähän huoneeseen ennen kuin jatkat."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Sinulla ei ole tällä hetkellä keskusteluja tämän yhteystiedon kanssa. Vahvista kutsusi hänelle tähän huoneeseen ennen kuin jatkat."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Kutsutaanko uusia yhteystietoja tähän huoneeseen?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Kutsutaanko uusi yhteystieto tähän huoneeseen?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Уже участник"</string>
<string name="screen_invite_users_already_invited">"Уже приглашен(а)"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"У тебя пока нет чатов с этими контактами. Подтверди приглашение в эту комнату, прежде чем продолжить."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"У вас пока нет чатов с этим контактом. Подтверди приглашение в эту комнату, прежде чем продолжить."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Пригласить новых участников в эту комнату?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Пригласить нового участника в эту комнату?"</string>
</resources>
@@ -609,6 +609,7 @@ private fun JoinRoomTopBar(
val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier
.clip(roundedCornerShape)
.semantics { heading() }
if (contentState.name != null) {
Row(
modifier = titleModifier,
@@ -621,10 +622,7 @@ private fun JoinRoomTopBar(
)
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.semantics {
heading()
},
.padding(horizontal = 8.dp),
text = contentState.name,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_ban_by_message">"Has estat bandejat per %1$s."</string>
<string name="screen_join_room_ban_message">"T\'han bandejat"</string>
<string name="screen_join_room_ban_reason">"Motiu: %1$s."</string>
<string name="screen_join_room_cancel_knock_action">"Cancel·la la sol·licitud"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Sí, cancel·la"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Segur que vols cancel·lar la teva sol·licitud d\'unió a aquesta sala?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Cancel·la la sol·licitud d\'unió"</string>
<string name="screen_join_room_decline_and_block_alert_confirmation">"Sí, rebutja i bloqueja"</string>
<string name="screen_join_room_decline_and_block_alert_message">"Segur que vols rebutjar la invitació d\'unió a aquesta sala? Això també evitarà que %1$s et contacti i et convidi a sales."</string>
<string name="screen_join_room_decline_and_block_alert_title">"Rebutja la invitació i bloqueja"</string>
<string name="screen_join_room_decline_and_block_button_title">"Rebutja i bloqueja"</string>
<string name="screen_join_room_fail_message">"Ha fallat la unió"</string>
<string name="screen_join_room_fail_reason">"O t\'han de convidar per unir-te o hi pot haver restriccions d\'accés."</string>
<string name="screen_join_room_forget_action">"Oblida"</string>
<string name="screen_join_room_invite_required_message">"Per unir-te, necessites una invitació"</string>
<string name="screen_join_room_join_action">"Uneix-te"</string>
<string name="screen_join_room_join_restricted_message">"Pot ser que t\'hagin de convidar o hagis de ser membre d\'un espai per unir-t\'hi."</string>
<string name="screen_join_room_knock_action">"Envia sol·licitud d\'unió"</string>
<string name="screen_join_room_knock_message_description">"Missatge (opcional)"</string>
<string name="screen_join_room_knock_sent_description">"Rebràs una invitació per unir-te a la sala si la teva sol·licitud és acceptada."</string>
<string name="screen_join_room_knock_sent_title">"Sol·licitud d\'unió enviada"</string>
<string name="screen_join_room_loading_alert_message">"No s\'ha pogut mostrar la vista prèvia de la sala. Pot ser degut a problemes de xarxa o del servidor."</string>
<string name="screen_join_room_loading_alert_title">"No s\'ha pogut mostrar la vista prèvia de la sala"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s encara no admet els espais. Pots accedir-hi a través del navegador web."</string>
<string name="screen_join_room_space_not_supported_title">"Espais encara no compatibles"</string>
<string name="screen_join_room_subtitle_knock">"Clic al botó següent i s\'avisarà a un administrador de sala. Podràs unir-te un cop t\'hagi aprovat."</string>
<string name="screen_join_room_subtitle_no_preview">"Per poder veure l\'historial de missatges has de ser un membre de la sala."</string>
<string name="screen_join_room_title_knock">"Vols unir-te a aquesta sala?"</string>
<string name="screen_join_room_title_no_preview">"Vista prèvia no disponible"</string>
</resources>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Sí, accepta-les totes"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Segur que vols acceptar totes les sol·licituds d\'unió?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Accepta totes les sol·licituds"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Accepta-les totes"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"No s\'han pogut acceptar totes les sol·licituds. Vols tornar-ho a intentar?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"No s\'han pogut acceptar totes les sol·licituds"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Acceptant totes les sol·licituds d\'unió"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"No s\'ha pogut acceptar la sol·licitud. Vols tornar-ho a intentar?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"No s\'ha pogut acceptar la sol·licitud"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Acceptant sol·licitud d\'unió"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Sí, rebutja i bandeja"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Segur que vols rebutjar i bandejar %1$s? L\'usuari no podrà sol·licitar de nou l\'accés d\'unió a aquesta sala."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Rebutja i bandeja l\'accés"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Rebutjant i bandejant l\'accés"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Sí, rebutja"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Segur que vols rebutjar %1$s d\'unir-se a aquesta sala?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Rebutja l\'accés"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Rebutja i bandeja"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"No s\'ha pogut rebutjar la sol·licitud. Vols tornar-ho a intentar?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"No s\'ha pogut rebutjar la sol·licitud"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Rebutjant sol·licitud d\'unió"</string>
<string name="screen_knock_requests_list_empty_state_description">"Quan algú demani unir-se a la sala, aquí podràs veure la sol·licitud."</string>
<string name="screen_knock_requests_list_empty_state_title">"No hi ha sol·licituds d\'unió pendents"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Carregant sol·licituds d\'unió…"</string>
<string name="screen_knock_requests_list_title">"Sol·licituds d\'unió"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d altre volen unir-se a la sala"</item>
<item quantity="other">"%1$s +%2$d altres volen unir-se a la sala"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Veure totes"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Accepta"</string>
<string name="screen_room_single_knock_request_title">"%1$s vol unir-se a aquesta sala"</string>
</resources>
@@ -17,7 +17,7 @@
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"是,拒绝"</string>
<string name="screen_knock_requests_list_decline_alert_description">"你确定要拒绝 %1$s 加入此房间的申请?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"拒绝访问"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"拒绝和禁止"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"拒绝并封禁"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"我们无法拒绝此申请。是否重试?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"拒绝申请失败"</string>
<string name="screen_knock_requests_list_decline_loading_title">"拒绝加入申请"</string>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Segur que vols sortir d\'aquest xat? El xat no és públic i no t\'hi podràs tornar a unir sense una invitació."</string>
<string name="leave_room_alert_empty_subtitle">"Segur que vols sortir d\'aquesta sala? N\'ets l\'única persona. Si en surts, ningú s\'hi podrà unir i tu tampoc."</string>
<string name="leave_room_alert_private_subtitle">"Segur que vols sortir d\'aquesta sala? La sala no és pública i no podràs tornar a unir-t\'hi sense una invitació."</string>
<string name="leave_room_alert_subtitle">"Segur que vols sortir de la sala?"</string>
</resources>
@@ -144,8 +144,14 @@ class LinkNewDeviceFlowNode(
navigateToError(linkMobileStep.errorType)
}
is LinkMobileStep.QrReady -> {
// The QrCode is ready, navigate to its display
backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
// The QrCode is ready, navigate to its display, if not already there
val navTarget = backstack.elements.value.last().key.navTarget
if (navTarget !is NavTarget.MobileShowQrCode) {
backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
}
}
LinkMobileStep.QrRotating -> {
// This step is handled in ShowQrCodePresenter
}
is LinkMobileStep.QrScanned -> {
backstack.replace(NavTarget.MobileEnterNumber)
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
@@ -65,4 +66,15 @@ class LinkNewMobileHandler(
linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
}
}
fun rotateQrCode() {
createAndStartNewHandler()
}
fun onTooManyRotation() {
reset()
sessionScope.launch {
linkMobileStepFlow.emit(LinkMobileStep.Error(ErrorType.Expired("Too many QR code rotations")))
}
}
}
@@ -147,7 +147,7 @@ private fun DoneButton(
}
@Composable
private fun Buttons(
private fun ColumnScope.Buttons(
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
@@ -25,6 +25,7 @@ import io.element.android.libraries.di.SessionScope
class ShowQrCodeNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
showQrCodePresenterFactory: ShowQrCodePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
class Inputs(
val data: String,
@@ -36,11 +37,15 @@ class ShowQrCodeNode(
private val inputs: Inputs = inputs<Inputs>()
private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data,
)
@Composable
override fun View(modifier: Modifier) {
val state = showQrCodePresenter.present()
ShowQrCodeView(
data = inputs.data,
state = state,
modifier = modifier,
onBackClick = callback::navigateBack,
)
@@ -0,0 +1,87 @@
/*
* 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.linknewdevice.impl.screens.qrcode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
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.linknewdevice.impl.LinkNewMobileHandler
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject
class ShowQrCodePresenter(
@Assisted private val initialData: String,
private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> {
@AssistedFactory
interface Factory {
fun create(initialData: String): ShowQrCodePresenter
}
private var loadingJob: Job? = null
@Composable
override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
val state by produceState(
initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData),
)
) {
linkNewMobileHandler.stepFlow.collect { step ->
when (step) {
is LinkMobileStep.QrReady -> {
loadingJob?.cancel()
value = ShowQrCodeState(
data = AsyncData.Success(step.data),
)
}
is LinkMobileStep.QrRotating -> {
if (qrCodeRotationCounter-- > 0) {
Timber.tag(tag.value).d("Rotating QrCode")
linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch {
delay(1000)
value = ShowQrCodeState(
data = AsyncData.Loading(),
)
}
} else {
Timber.tag(tag.value).w("Max QR code rotation reached, not rotating anymore")
linkNewMobileHandler.onTooManyRotation()
}
}
else -> Unit
}
}
}
return state
}
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
}
@@ -0,0 +1,14 @@
/*
* 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.linknewdevice.impl.screens.qrcode
import io.element.android.libraries.architecture.AsyncData
data class ShowQrCodeState(
val data: AsyncData<String>,
)
@@ -0,0 +1,27 @@
/*
* 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.linknewdevice.impl.screens.qrcode
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
class ShowQrCodeStateProvider : PreviewParameterProvider<ShowQrCodeState> {
override val values: Sequence<ShowQrCodeState>
get() = sequenceOf(
aShowQrCodeState(),
aShowQrCodeState(
data = AsyncData.Loading(),
),
)
}
internal fun aShowQrCodeState(
data: AsyncData<String> = AsyncData.Success("DATA"),
) = ShowQrCodeState(
data = data,
)
@@ -9,6 +9,12 @@
package io.element.android.features.linknewdevice.impl.screens.qrcode
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,6 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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.linknewdevice.impl.R
@@ -30,6 +37,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.qrcode.QrCodeImage
import kotlinx.collections.immutable.persistentListOf
@@ -38,9 +46,10 @@ import kotlinx.collections.immutable.persistentListOf
* QrCode display screen:
* https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun ShowQrCodeView(
data: String,
state: ShowQrCodeState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -55,11 +64,17 @@ fun ShowQrCodeView(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCodeImage(
data = data,
modifier = Modifier
.size(220.dp)
)
AnimatedContent(
modifier = Modifier.size(220.dp),
targetState = state.data.dataOrNull(),
transitionSpec = {
fadeIn().togetherWith(fadeOut())
}
) { data ->
QrCodeOrLoading(
data = data,
)
}
Spacer(modifier = Modifier.height(32.dp))
NumberedListOrganism(
modifier = Modifier.fillMaxSize(),
@@ -79,11 +94,33 @@ fun ShowQrCodeView(
}
}
@Composable
private fun QrCodeOrLoading(
data: String?,
modifier: Modifier = Modifier,
) {
if (data == null) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
QrCodeImage(
modifier = modifier,
data = data,
)
}
}
@PreviewsDayNight
@Composable
internal fun ShowQrCodeViewPreview() = ElementPreview {
internal fun ShowQrCodeViewPreview(
@PreviewParameter(ShowQrCodeStateProvider::class) state: ShowQrCodeState,
) = ElementPreview {
ShowQrCodeView(
data = "DATA",
state = state,
onBackClick = { },
)
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Escaneja el QR"</string>
<string name="screen_link_new_device_desktop_step3">"Escaneja el codi QR amb aquest dispositiu"</string>
<string name="screen_link_new_device_desktop_submit">"Preparat per escanejar"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"El proveïdor del teu compte no admet %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s no és compatible"</string>
<string name="screen_link_new_device_error_not_supported_title">"Codi QR no compatible"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"L\'inici de sessió s\'ha cancel·lat a l\'altre dispositiu."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Sol·licitud d\'inici de sessió cancel·lada"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Inici de sessió ha caducat. Torna-ho a provar."</string>
<string name="screen_link_new_device_error_request_timeout_title">"L\'inici de sessió no s\'ha completat a temps"</string>
<string name="screen_link_new_device_mobile_step2">"Selecciona %1$s"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"No s\'ha pogut establir una connexió segura amb el dispositiu nou. Els dispositius existents continuen sent segurs, no te n\'has de preocupar."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"I ara què?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Prova de tornar a iniciar sessió mitjançant un codi QR si es tracta d\'un problema de xarxa."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Si es repeteix el mateix problema, prova una xarxa wifi diferent o utilitza les dades mòbils en lloc del wifi."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Si no funciona, inicia sessió manualment"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Connexió no segura"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Se\'t demanarà que introdueixis els dos dígits mostrats en aquest dispositiu."</string>
<string name="screen_qr_code_login_device_code_title">"Introdueix el número següent a l\'altre dispositiu"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"L\'inici de sessió s\'ha cancel·lat a l\'altre dispositiu."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Sol·licitud d\'inici de sessió cancel·lada"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"L\'inici de sessió s\'ha rebutjat a l\'altre dispositiu."</string>
<string name="screen_qr_code_login_error_declined_title">"Inici de sessió rebutjat"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Inici de sessió ha caducat. Torna-ho a provar."</string>
<string name="screen_qr_code_login_error_expired_title">"L\'inici de sessió no s\'ha completat a temps"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"El teu altre dispositiu no admet l\'inici de sessió a %s amb codis QR.
Prova d\'iniciar la sessió manualment o escaneja el QR amb un altre dispositiu."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"Codi QR no compatible"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"El proveïdor del teu compte no admet %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s no és compatible"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Utilitza el codi QR que es mostra a l\'altre dispositiu."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Torna-ho a intentar"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Codi QR incorrecte"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Per continuar, has donar permís a %1$s per poder utilitzar la càmera del dispositiu."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Permet l\'accés a la càmera per poder escanejar el codi QR"</string>
<string name="screen_qr_code_login_unknown_error_description">"S\'ha produït un error inesperat. Torna-ho a provar."</string>
<string name="screen_qr_code_login_verify_code_loading">"Esperant el teu altre dispositiu"</string>
</resources>
@@ -9,7 +9,7 @@
<string name="screen_link_new_device_enter_number_notice">"输入两位数字的代码"</string>
<string name="screen_link_new_device_enter_number_subtitle">"这将验证你与其它设备的连接是否安全。"</string>
<string name="screen_link_new_device_enter_number_title">"请输入另一台设备上显示的数字"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"账户提供方不支持 %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"账户提供者不支持 %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"不支持 %1$s."</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"你的账户提供者不支持使用二维码登录到新设备。"</string>
<string name="screen_link_new_device_error_not_supported_title">"二维码不受支持"</string>
@@ -48,7 +48,7 @@
尝试手动或使用另一个设备扫描二维码."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"二维码不受支持"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"账户提供方不支持 %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"账户提供者不支持 %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"不支持 %1$s."</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"使用其它设备上显示的二维码。"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"重试"</string>
@@ -0,0 +1,100 @@
/*
* 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.linknewdevice.impl.screens.qrcode
import com.google.common.truth.Truth.assertThat
import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ShowQrCodePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
createPresenter().test {
val initialState = awaitItem()
assertThat(initialState.data.dataOrNull()).isEqualTo("DATA")
}
}
@Test
fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val createLinkMobileHandlerResult = lambdaRecorder<Result<LinkMobileHandler>> {
Result.success(linkMobileHandler)
}
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = createLinkMobileHandlerResult,
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
val finalState = awaitItem()
assertThat(finalState.data.isLoading()).isTrue()
createLinkMobileHandlerResult.assertions().isCalledExactly(2)
}
}
@Test
fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode and the code is rotated`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
linkMobileHandler.emitStep(
LinkMobileStep.QrReady("DATA2")
)
val finalState = awaitItem()
assertThat(finalState.data.dataOrNull()).isEqualTo("DATA2")
}
}
private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
) = ShowQrCodePresenter(
initialData = "DATA",
linkNewMobileHandler = linkNewMobileHandler,
)
}
@@ -37,7 +37,7 @@ class ShowQrCodeViewTest {
) {
setContent {
ShowQrCodeView(
data = "DATA",
state = aShowQrCodeState(),
onBackClick = onBackClick,
)
}
@@ -25,8 +25,18 @@ internal class MapTilerTileServerStyleUriBuilder(
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(darkMode: Boolean): String {
val mapId = if (darkMode) darkMapId else lightMapId
return "$baseUrl/$mapId/style.json?key=$apiKey"
override fun build(
customMapStyleUrl: String?,
darkMode: Boolean,
): String {
return buildString {
if (customMapStyleUrl.isNullOrBlank()) {
val mapId = if (darkMode) darkMapId else lightMapId
append("$baseUrl/$mapId/style.json")
} else {
append(customMapStyleUrl)
}
append("?key=$apiKey")
}
}
}
@@ -19,21 +19,25 @@ import io.element.android.compound.theme.ElementTheme
*/
interface TileServerStyleUriBuilder {
fun build(
customMapStyleUrl: String?,
darkMode: Boolean,
): String
}
fun TileServerStyleUriBuilder(): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder()
/**
* Provides and remembers a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
@Composable
fun rememberTileStyleUrl(): String {
fun rememberTileStyleUrl(
customMapStyleUrl: String?,
): String {
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
TileServerStyleUriBuilder().build(darkMode)
return remember(darkMode, customMapStyleUrl) {
MapTilerTileServerStyleUriBuilder().build(
customMapStyleUrl = customMapStyleUrl,
darkMode = darkMode,
)
}
}
@@ -22,14 +22,40 @@ class MapTilerTileServerStyleUriBuilderTest {
@Test
fun `light map uri`() {
assertThat(
builder.build(darkMode = false)
builder.build(
customMapStyleUrl = null,
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
}
@Test
fun `dark map uri`() {
assertThat(
builder.build(darkMode = true)
builder.build(
customMapStyleUrl = null,
darkMode = true,
)
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
}
@Test
fun `custom map uri light`() {
assertThat(
builder.build(
customMapStyleUrl = "https://custom.url/style.json",
darkMode = false,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
}
@Test
fun `custom map uri dark`() {
assertThat(
builder.build(
customMapStyleUrl = "https://custom.url/style.json",
darkMode = true,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
}
}
@@ -0,0 +1,129 @@
/*
* 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.location.impl.common
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.RequiresPermission
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import androidx.core.os.ExecutorCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.stateIn
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationProvider
import org.maplibre.compose.location.PermissionException
import org.maplibre.compose.location.asMapLibreLocation
import org.maplibre.spatialk.units.Length
import org.maplibre.spatialk.units.extensions.inMeters
import kotlin.time.Duration
@SuppressLint("InlinedApi")
class PlatformLocationProvider(
context: Context,
private val updateInterval: Duration,
private val minDistance: Length,
private val desiredAccuracy: DesiredAccuracy = DesiredAccuracy.High,
coroutineScope: CoroutineScope,
sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000),
) : LocationProvider {
override val location: StateFlow<Location?>
init {
if (!handlerThread.isAlive) handlerThread.start()
if (
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
) {
throw PermissionException()
}
val locationManager = context.getSystemService(LocationManager::class.java)
val provider = PROVIDERS_BY_PRIORITY.firstOrNull { LocationManagerCompat.hasProvider(locationManager, it) }
val locationFlow = if (provider != null) {
createProviderFlow(locationManager, provider)
} else {
emptyFlow()
}
location = locationFlow.stateIn(coroutineScope, sharingStarted, null)
}
@RequiresPermission(
anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION]
)
private fun createProviderFlow(locationManager: LocationManager, provider: String) = callbackFlow {
send(locationManager.getLastKnownLocation(provider)?.asMapLibreLocation())
val listener = LocationListenerCompat { trySend(it.asMapLibreLocation()) }
val request = LocationRequestCompat.Builder(updateInterval.inWholeMilliseconds)
.setQuality(desiredAccuracy.toLocationRequestQuality())
.setMinUpdateDistanceMeters(minDistance.inMeters.toFloat())
.build()
LocationManagerCompat.requestLocationUpdates(
locationManager,
provider,
request,
ExecutorCompat.create(Handler(handlerThread.looper)),
listener,
)
awaitClose { LocationManagerCompat.removeUpdates(locationManager, listener) }
}
private companion object {
private val PROVIDERS_BY_PRIORITY = listOf(
LocationManager.FUSED_PROVIDER,
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
)
private val handlerThread by lazy { HandlerThread("PlatformLocationProvider") }
}
}
private fun DesiredAccuracy.toLocationRequestQuality(): Int = when (this) {
DesiredAccuracy.Highest, DesiredAccuracy.High -> LocationRequestCompat.QUALITY_HIGH_ACCURACY
DesiredAccuracy.Balanced -> LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY
DesiredAccuracy.Low, DesiredAccuracy.Lowest -> LocationRequestCompat.QUALITY_LOW_POWER
}
@Composable
@RequiresPermission(
anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION]
)
fun rememberPlatformLocationProvider(
updateInterval: Duration,
minDistance: Length,
desiredAccuracy: DesiredAccuracy = DesiredAccuracy.High,
context: Context = LocalContext.current,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000),
): PlatformLocationProvider {
return remember(context, updateInterval, minDistance, desiredAccuracy, coroutineScope, sharingStarted) {
PlatformLocationProvider(
context = context,
updateInterval = updateInterval,
minDistance = minDistance,
desiredAccuracy = desiredAccuracy,
coroutineScope = coroutineScope,
sharingStarted = sharingStarted,
)
}
}
@@ -0,0 +1,42 @@
/*
* 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.location.impl.common
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalInspectionMode
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.Duration.Companion.seconds
class UserLocationState(locationState: State<Location?>) {
val location: Location? by locationState
}
@SuppressLint("MissingPermission")
@Composable
fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState {
val isPreview = LocalInspectionMode.current
val locationProvider = if (isPreview || !hasLocationPermission) {
rememberNullLocationProvider()
} else {
rememberPlatformLocationProvider(
updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.High,
minDistance = 5.meters,
)
}
val locationState = locationProvider.location.collectAsState()
return remember { UserLocationState(locationState) }
}
@@ -14,6 +14,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.Location
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap
import kotlinx.collections.immutable.ImmutableList
import kotlinx.serialization.json.JsonPrimitive
import org.maplibre.compose.expressions.dsl.and
import org.maplibre.compose.expressions.dsl.asString
@@ -66,7 +67,7 @@ data class LocationMarkerData(
*/
@Composable
fun LocationPinMarkers(
markers: List<LocationMarkerData>,
markers: ImmutableList<LocationMarkerData>,
onMarkerClick: ((LocationMarkerData) -> Unit)? = null,
onClusterClick: ((Position) -> Unit)? = null,
) {
@@ -37,6 +37,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
@@ -44,9 +45,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
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
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 org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions
@@ -63,6 +66,7 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning
*
* @param customMapStyleUrl Optional custom style URL for the map
* @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map
@@ -79,6 +83,7 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapBottomSheetScaffold(
customMapStyleUrl: AsyncData<String?>,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
@@ -130,13 +135,22 @@ fun MapBottomSheetScaffold(
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
Box {
MaplibreMap(
options = mapOptions,
baseStyle = BaseStyle.Uri(rememberTileStyleUrl()),
modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
)
when (customMapStyleUrl) {
is AsyncData.Success -> {
MaplibreMap(
options = mapOptions,
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapStyleUrl.data)),
modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
)
}
else -> {
Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
overlayContent(sheetPadding)
}
}
@@ -0,0 +1,51 @@
/*
* 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.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import io.element.android.features.location.impl.common.UserLocationState
import kotlinx.coroutines.flow.distinctUntilChanged
import org.maplibre.compose.location.Location
import kotlin.math.abs
/**
* Drop-in replacement for the library's LocationTrackingEffect.
* TODO remove once https://github.com/maplibre/maplibre-compose/issues/808 is fixed
*/
@Composable
internal fun SimpleLocationTrackingEffect(
locationState: UserLocationState,
enabled: Boolean = true,
precision: Double = 0.00001,
onLocationChange: suspend (Location?) -> Unit,
) {
val latestOnLocationChange by rememberUpdatedState(onLocationChange)
LaunchedEffect(locationState, enabled) {
if (!enabled) return@LaunchedEffect
val locationStateFlow = snapshotFlow { locationState.location }
locationStateFlow
.distinctUntilChanged { oldLocation, newLocation ->
if (oldLocation != null && newLocation != null) {
when {
abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false
abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false
else -> true
}
} else {
false
}
}
.collect { location ->
latestOnLocationChange(location)
}
}
}
@@ -7,23 +7,15 @@
package io.element.android.features.location.impl.common.ui
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.UserLocationState
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.compose.location.LocationTrackingEffect
import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.location.rememberAndroidLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
import kotlin.time.Duration.Companion.seconds
@Composable
fun UserLocationPuck(
@@ -31,22 +23,24 @@ fun UserLocationPuck(
locationState: UserLocationState,
trackUserLocation: Boolean,
) {
LocationTrackingEffect(
SimpleLocationTrackingEffect(
locationState = locationState,
enabled = trackUserLocation,
) {
val finalPosition = cameraState.position.copy(
target = currentLocation.position,
bearing = currentLocation.bearing ?: cameraState.position.bearing,
) { currentLocation ->
val target = currentLocation?.position?.value ?: cameraState.position.target
val newPosition = cameraState.position.copy(
target = target,
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
)
cameraState.animateTo(finalPosition)
cameraState.animateTo(newPosition)
}
val location = locationState.location
if (location != null) {
LocationPuck(
idPrefix = "user-location",
locationState = locationState,
location = location,
cameraState = cameraState,
accuracyThreshold = Float.POSITIVE_INFINITY,
showBearingAccuracy = false,
@@ -63,19 +57,3 @@ fun UserLocationPuck(
)
}
}
@SuppressLint("MissingPermission")
@Composable
fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState {
val isPreview = LocalInspectionMode.current
val locationProvider = if (isPreview || !hasLocationPermission) {
rememberNullLocationProvider()
} else {
rememberAndroidLocationProvider(
updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.High,
minDistanceMeters = 5f,
)
}
return rememberUserLocationState(locationProvider)
}
@@ -133,6 +133,11 @@ class DefaultActiveLiveLocationShareManager(
}
}
override suspend fun onUnrecoverableError() {
Timber.d("ActiveLiveLocationShareManager unrecoverable error, stopping all shares")
localSharingRoomIds.value.toList().forEach { stopShare(it) }
}
override suspend fun onLocationUpdate(location: Location) {
val activeSharesCount = localSharingRoomIds.value.size
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
@@ -9,6 +9,7 @@ package io.element.android.features.location.impl.live.service
import io.element.android.features.location.api.Location
fun interface LiveLocationReceiver {
interface LiveLocationReceiver {
suspend fun onLocationUpdate(location: Location)
suspend fun onUnrecoverableError()
}
@@ -77,6 +77,17 @@ class LiveLocationSharingCoordinator internal constructor(
}
}
suspend fun dispatchUnrecoverableError() {
Timber.d("LiveLocationSharingCoordinator dispatching unrecoverable error")
receivers.forEach { (sessionId, receiver) ->
runCatchingExceptions {
receiver.onUnrecoverableError()
}.onFailure {
Timber.e(it, "Failed to dispatch unrecoverable error for session $sessionId")
}
}
}
suspend fun dispatch(location: Location) {
val currentTimeMillis = nowMillis()
val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load()
@@ -14,6 +14,7 @@ import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
import android.os.IBinder
import androidx.core.app.ServiceCompat
import dev.zacsweers.metro.Inject
import io.element.android.features.location.impl.common.PlatformLocationProvider
import io.element.android.features.location.impl.di.LocationBindings
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
import io.element.android.libraries.architecture.bindings
@@ -29,13 +30,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import org.maplibre.compose.location.AndroidLocationProvider
import kotlinx.coroutines.launch
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.PermissionException
import org.maplibre.spatialk.units.extensions.inMeters
import org.maplibre.spatialk.units.extensions.meters
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import io.element.android.features.location.api.Location as ApiLocation
@@ -46,7 +51,6 @@ class LiveLocationSharingService : Service() {
@Inject lateinit var coordinator: LiveLocationSharingCoordinator
@Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var appForegroundStateService: AppForegroundStateService
@AppCoroutineScope
@@ -60,8 +64,8 @@ class LiveLocationSharingService : Service() {
override fun onCreate() {
super.onCreate()
Timber.d("LiveLocationSharingService onCreate")
bindings<LocationBindings>().inject(this)
runCatchingExceptions {
bindings<LocationBindings>().inject(this)
appForegroundStateService.updateIsSharingLiveLocation(true)
coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService")
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION)
@@ -79,6 +83,7 @@ class LiveLocationSharingService : Service() {
startLocationUpdatesListener()
}.onFailure {
Timber.e(it, "Failed to start live location sharing service")
appCoroutineScope.launch { coordinator.dispatchUnrecoverableError() }
stopSelf()
}
}
@@ -88,21 +93,26 @@ class LiveLocationSharingService : Service() {
Timber.d("LiveLocationSharingService listening to location updates")
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
.flatMapLatest { minDistanceMeters ->
val locationProvider = AndroidLocationProvider(
context = applicationContext,
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
minDistanceMeters = minDistanceMeters.toFloat(),
desiredAccuracy = DesiredAccuracy.Balanced,
coroutineScope = coroutineScope
)
locationProvider.location
try {
PlatformLocationProvider(
context = applicationContext,
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
minDistance = minDistanceMeters.meters,
desiredAccuracy = DesiredAccuracy.Balanced,
coroutineScope = coroutineScope
).location
} catch (exception: PermissionException) {
Timber.e(exception, "Failed to create PlatformLocationProvider")
coordinator.dispatchUnrecoverableError()
emptyFlow()
}
}
.filterNotNull()
.map { location ->
ApiLocation(
lat = location.position.latitude,
lon = location.position.longitude,
accuracy = location.accuracy.toFloat(),
lat = location.position.value.latitude,
lon = location.position.value.longitude,
accuracy = location.position.accuracy?.inMeters?.toFloat(),
)
}
.onEach(coordinator::dispatch)
@@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -35,6 +36,7 @@ import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
@@ -86,6 +88,10 @@ class ShareLocationPresenter(
}
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
val customMapStyleUrl by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
}
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
}
@@ -94,8 +100,8 @@ class ShareLocationPresenter(
fun checkLocationConstraints() {
// No need to check SendLiveLocationPermissions here
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
}
suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog {
@@ -161,6 +167,7 @@ class ShareLocationPresenter(
}
return ShareLocationState(
customMapStyleUrl = customMapStyleUrl,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -10,10 +10,12 @@ package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
val customMapStyleUrl: AsyncData<String?>,
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.share
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
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 kotlinx.collections.immutable.persistentListOf
@@ -76,10 +77,14 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
hasLocationPermission = true,
canShareLiveLocation = true,
),
aShareLocationState(
customMapStyleUrl = AsyncData.Loading(),
),
)
}
fun aShareLocationState(
customMapStyleUrl: AsyncData<String?> = AsyncData.Success(null),
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
@@ -90,6 +95,7 @@ fun aShareLocationState(
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
customMapStyleUrl = customMapStyleUrl,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -26,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -38,11 +39,12 @@ import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.UserLocationState
import io.element.android.features.location.impl.common.rememberUserLocationState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.LocationPin
@@ -68,7 +70,6 @@ import kotlinx.collections.immutable.ImmutableList
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.location.UserLocationState
import kotlin.time.Duration
@OptIn(ExperimentalMaterial3Api::class)
@@ -117,6 +118,7 @@ fun ShareLocationView(
}
MapBottomSheetScaffold(
customMapStyleUrl = state.customMapStyleUrl,
cameraState = cameraState,
modifier = modifier,
scaffoldState = scaffoldState,
@@ -179,6 +181,7 @@ private fun StartLiveLocationActionView(
onActionSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
val updatedOnActionSuccess by rememberUpdatedState(onActionSuccess)
Box(modifier = modifier) {
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(state = asyncIndicatorState)
@@ -201,7 +204,7 @@ private fun StartLiveLocationActionView(
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { onActionSuccess() }
LaunchedEffect(action) { updatedOnActionSuccess() }
}
else -> Unit
}
@@ -222,8 +225,8 @@ private fun BottomSheetContent(
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(
lat = userLocation.position.latitude,
lon = userLocation.position.longitude
lat = userLocation.position.value.latitude,
lon = userLocation.position.value.longitude
),
isPinned = false
)
@@ -33,6 +33,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta
@@ -40,6 +41,7 @@ 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.MatrixClient
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,6 +60,7 @@ class ShowLocationPresenter(
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
private val client: MatrixClient,
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
) : Presenter<ShowLocationState> {
@@ -78,6 +81,11 @@ class ShowLocationPresenter(
mutableStateOf(LocationConstraintsDialogState.None)
}
val customMapStyleUrl by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
}
LaunchedEffect(permissionsState.permissions) {
if (permissionsState.isAnyGranted) {
dialogState = LocationConstraintsDialogState.None
@@ -188,6 +196,7 @@ class ShowLocationPresenter(
}
return ShowLocationState(
customMapStyleUrl = customMapStyleUrl,
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show
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
import io.element.android.libraries.architecture.AsyncData
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
@@ -18,6 +19,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
val customMapStyleUrl: AsyncData<String?>,
val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>,
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
@@ -41,12 +42,16 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
hasLocationPermission = true,
isTrackMyLocation = true,
),
aShowLocationState(
customMapStyleUrl = AsyncData.Loading(),
),
)
}
private const val APP_NAME = "ApplicationName"
fun aShowLocationState(
customMapStyleUrl: AsyncData<String?> = AsyncData.Success(null),
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
@@ -57,6 +62,7 @@ fun aShowLocationState(
eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState {
return ShowLocationState(
customMapStyleUrl = customMapStyleUrl,
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
@@ -37,19 +37,20 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.rememberUserLocationState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.LocationPinMarkers
import io.element.android.features.location.impl.common.ui.LocationShareRow
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraPosition
@@ -100,6 +101,7 @@ fun ShowLocationView(
}
}
MapBottomSheetScaffold(
customMapStyleUrl = state.customMapStyleUrl,
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
@@ -153,7 +155,9 @@ fun ShowLocationView(
val position = CameraPosition(
padding = sheetPaddings,
target = Position(locationShare.location.lon, locationShare.location.lat),
zoom = MapDefaults.DEFAULT_ZOOM
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
@@ -171,7 +175,7 @@ fun ShowLocationView(
trackUserLocation = state.isTrackMyLocation
)
val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }
state.locationShares.map { it.toMarkerData() }.toImmutableList()
}
LocationPinMarkers(markers)
},
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Reaaliaikainen sijaintihistoriasi tallennetaan huoneeseen ja on jäsenten nähtävissä istunnon päätyttyä."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi."</string>
<string name="screen_share_location_live_location_missing_permissions">"Sinulla ei ole oikeuksia jakaa reaaliaikaista sijaintiasi tässä huoneessa"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"ライブ位置情報の履歴はルームに保管され、メンバーは後から確認することもできます。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"ライブ位置情報を共有する期間を選択してください。"</string>
<string name="screen_share_location_live_location_missing_permissions">"このルームでライブ位置情報を共有する権限がありません。"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"История вашего местоположения в режиме реального времени будет сохранена в комнате и станет доступна участникам после окончания сессии."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Выберите, как долго вы будете делиться своим местоположением в режиме реального времени."</string>
<string name="screen_share_location_live_location_missing_permissions">"У тебя нет прав на то, чтобы делиться своим текущим местоположением в этой комнате"</string>
</resources>
@@ -27,7 +27,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { 0L },
)
coordinator.register(A_SESSION_ID, LiveLocationReceiver { })
coordinator.register(A_SESSION_ID, liveLocationReceiver())
coordinator.unregister(A_SESSION_ID)
assertThat(startCount).isEqualTo(1)
@@ -43,8 +43,8 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { 4_000L },
)
coordinator.register(A_SESSION_ID) { error("boom") }
coordinator.register(A_SESSION_ID_2) { location -> delivered += location }
coordinator.register(A_SESSION_ID, liveLocationReceiver { error("boom") })
coordinator.register(A_SESSION_ID_2, liveLocationReceiver { delivered += it })
coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
@@ -60,7 +60,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
coordinator.register(A_SESSION_ID, liveLocationReceiver { delivered += it })
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
@@ -79,7 +79,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
coordinator.register(A_SESSION_ID, liveLocationReceiver { delivered += it })
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
@@ -101,7 +101,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
coordinator.register(A_SESSION_ID, liveLocationReceiver { delivered += it })
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
@@ -113,3 +113,10 @@ class LiveLocationSharingCoordinatorTest {
assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder()
}
}
private fun liveLocationReceiver(
onLocation: suspend (Location) -> Unit = {},
) = object : LiveLocationReceiver {
override suspend fun onLocationUpdate(location: Location) = onLocation(location)
override suspend fun onUnrecoverableError() = Unit
}
@@ -15,6 +15,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
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
@@ -75,7 +76,10 @@ class ShareLocationPresenterTest {
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private val fakeMatrixClient = FakeMatrixClient(
sessionId = A_USER_ID,
getMapStyleUrlResult = { Result.success(null) },
)
private val durationFormatter = FakeDurationFormatter()
@@ -85,6 +89,7 @@ class ShareLocationPresenterTest {
locationActions: FakeLocationActions = fakeLocationActions,
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
client: FakeMatrixClient = fakeMatrixClient,
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
@@ -93,7 +98,7 @@ class ShareLocationPresenterTest {
messageComposerContext = fakeMessageComposerContext,
locationActions = locationActions,
buildMeta = fakeBuildMeta,
client = fakeMatrixClient,
client = client,
durationFormatter = durationFormatter,
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
@@ -110,14 +115,31 @@ class ShareLocationPresenterTest {
val shareLocationPresenter = createShareLocationPresenter()
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
assertThat(state.customMapStyleUrl.isLoading()).isFalse()
assertThat(state.trackUserLocation).isTrue()
assertThat(state.hasLocationPermission).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
val shareLocationPresenter = createShareLocationPresenter(
client = FakeMatrixClient(
sessionId = A_USER_ID,
getMapStyleUrlResult = { Result.success("aUrl") },
)
)
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.customMapStyleUrl.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
}
}
@Test
fun `initial state with permissions partially granted and location enabled`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
@@ -131,8 +153,7 @@ class ShareLocationPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isTrue()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
@@ -152,8 +173,7 @@ class ShareLocationPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
@@ -173,8 +193,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
@@ -195,8 +214,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(
@@ -216,8 +234,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isTrue()
initialState.eventSink(ShareLocationEvent.StopTrackingUserLocation)
@@ -237,8 +254,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
)
@@ -260,7 +276,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
val initialState = awaitItem()
val initialState = awaitFirstItem()
initialState.eventSink(ShareLocationEvent.RequestPermissions)
// Wait for dialog to be dismissed
@@ -282,8 +298,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
initialState.eventSink(ShareLocationEvent.OpenAppSettings)
val settingsOpenedState = awaitItem()
@@ -304,8 +319,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
@@ -337,8 +351,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val durationDialogState = awaitItem()
@@ -358,8 +371,7 @@ class ShareLocationPresenterTest {
)
presenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val dialogState = awaitItem()
@@ -385,8 +397,7 @@ class ShareLocationPresenterTest {
)
presenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
awaitItem()
@@ -413,8 +424,7 @@ class ShareLocationPresenterTest {
)
presenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val disclaimerState = awaitItem()
@@ -444,8 +454,7 @@ class ShareLocationPresenterTest {
)
presenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val durationState = awaitItem()
@@ -472,8 +481,7 @@ class ShareLocationPresenterTest {
)
presenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val dialogState = awaitItem()
@@ -504,8 +512,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
// Dismiss initial dialog
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
@@ -539,8 +546,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
val initialState = awaitFirstItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
@@ -593,7 +599,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
val initialState = awaitItem()
val initialState = awaitFirstItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
@@ -641,8 +647,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration = 1.hours))
advanceUntilIdle()
assert(startShareLambda).isCalledOnce().with(
@@ -659,8 +664,7 @@ class ShareLocationPresenterTest {
timelineMode = Timeline.Mode.Live,
)
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
assertThat(state.canShareLiveLocation).isTrue()
}
}
@@ -671,11 +675,15 @@ class ShareLocationPresenterTest {
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
)
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
val state = awaitFirstItem()
assertThat(state.canShareLiveLocation).isFalse()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(2)
return awaitItem()
}
}
private fun createLiveLocationStore(
@@ -19,6 +19,7 @@ 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.FakeMatrixClient
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
@@ -48,6 +49,7 @@ class DefaultShowLocationEntryPointTest {
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
client = FakeMatrixClient(),
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
)
},
@@ -22,10 +22,13 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.MatrixClient
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_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
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
@@ -61,6 +64,7 @@ class ShowLocationPresenterTest {
),
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
client: MatrixClient = FakeMatrixClient(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
) = ShowLocationPresenter(
mode = mode,
@@ -70,6 +74,7 @@ class ShowLocationPresenterTest {
dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
client = client,
liveLocationShareManager = liveLocationShareManager,
)
@@ -85,11 +90,28 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.customMapStyleUrl.isLoading()).isTrue()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
val shareLocationPresenter = createShowLocationPresenter(
client = FakeMatrixClient(
sessionId = A_USER_ID,
getMapStyleUrlResult = { Result.success("aUrl") },
)
)
shareLocationPresenter.test {
val state = awaitItem()
assertThat(state.customMapStyleUrl.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
}
}
@Test
fun `emits initial state location permission denied once`() = runTest {
fakePermissionsPresenter.givenState(
@@ -146,8 +168,13 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
val presenter = createShowLocationPresenter()
val presenter = createShowLocationPresenter(
client = FakeMatrixClient(
getMapStyleUrlResult = { Result.success(null) }
)
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_app_lock_biometric_authentication">"l\'autenticació biomètrica"</string>
<string name="screen_app_lock_biometric_unlock">"desbloqueig biomètric"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Desbloqueja amb biometria"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Confirma biometria"</string>
<string name="screen_app_lock_forgot_pin">"Has oblidat el PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Canvia codi PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Permet desbloqueig biomètric"</string>
<string name="screen_app_lock_settings_remove_pin">"Elimina PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Segur que vols eliminar el PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Vols eliminar el PIN?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Permet %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Prefereixo utilitzar el PIN"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Estalvia\'t temps i utilitza %1$s per desbloquejar l\'aplicació"</string>
<string name="screen_app_lock_setup_choose_pin">"Escull el PIN"</string>
<string name="screen_app_lock_setup_confirm_pin">"Confirma PIN"</string>
<string name="screen_app_lock_setup_pin_context">"Bloqueja %1$s per afegir més seguretat als teus xats.
Escull alguna cosa que recordis. Si oblides aquest PIN, es tancarà sessió a l\'aplicació."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Per motius de seguretat no pots utilitzar aquest codi PIN"</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Escull un PIN diferent"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Introdueix el mateix PIN dues vegades"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Els codis PIN no coincideixen"</string>
<string name="screen_app_lock_signout_alert_message">"Hauràs de tornar a iniciar sessió i crear un nou PIN per continuar."</string>
<string name="screen_app_lock_signout_alert_title">"S\'està tancant la sessió"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Tens %1$d intent per desbloquejar"</item>
<item quantity="other">"Tens %1$d intents per desbloquejar"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"PIN incorrecte. Tens %1$d intent més"</item>
<item quantity="other">"PIN incorrecte. Tens %1$d intents més"</item>
</plurals>
<string name="screen_app_lock_use_biometric_android">"Utilitza biometria"</string>
<string name="screen_app_lock_use_pin_android">"Utilitza PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"S\'està tancant la sessió…"</string>
</resources>
@@ -125,7 +125,7 @@ private fun Content(errorScreenType: QrCodeErrorScreenType) {
}
@Composable
private fun Buttons(
private fun ColumnScope.Buttons(
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Canvia el proveïdor del compte"</string>
<string name="screen_account_provider_form_hint">"Adreça del servidor"</string>
<string name="screen_account_provider_form_notice">"Introdueix una paraula de cerca o un domini (adreça)."</string>
<string name="screen_account_provider_form_subtitle">"Cerca una empresa, una comunitat o un servidor privat."</string>
<string name="screen_account_provider_form_title">"Busca un proveïdor de comptes"</string>
<string name="screen_account_provider_signin_subtitle">"Aquí és on es guardaran els teus xats, de manera similar a utilitzar un proveïdor de correu electrònic per guardar els teus correus electrònics."</string>
<string name="screen_account_provider_signin_title">"Estàs a punt d\'iniciar sessió a %s"</string>
<string name="screen_account_provider_signup_subtitle">"Aquí és on es guardaran els teus xats, de manera similar a utilitzar un proveïdor de correu electrònic per guardar els teus correus electrònics."</string>
<string name="screen_account_provider_signup_title">"Estàs a punt de crear un compte a %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org és un gran servidor gratuït de la xarxa pública de Matrix per a la comunicació segura i descentralitzada, gestionat per la Fundació Matrix.org."</string>
<string name="screen_change_account_provider_other">"Altres"</string>
<string name="screen_change_account_provider_subtitle">"Utilitza un proveïdor de comptes diferent, com ara el teu servidor privat o un compte de feina."</string>
<string name="screen_change_account_provider_title">"Canvia el proveïdor del compte"</string>
<string name="screen_change_server_error_invalid_homeserver">"No s\'ha pogut accedir a aquest servidor. Comprova que hagis introduït correctament l\'URL del servidor. Si ja és correcte, posa\'t en contacte amb l\'administrador del servidor per a més informació."</string>
<string name="screen_change_server_error_invalid_well_known">"Servidor no disponible a causa d\'un problema al fitxer .well-known:
%1$s"</string>
<string name="screen_change_server_form_header">"URL del servidor"</string>
<string name="screen_change_server_form_notice">"Introdueix un domini."</string>
<string name="screen_change_server_subtitle">"Quina és l\'adreça del teu servidor?"</string>
<string name="screen_change_server_title">"Selecciona el teu servidor"</string>
<string name="screen_create_account_title">"Crea un compte"</string>
<string name="screen_login_error_deactivated_account">"Aquest compte s\'ha desactivat."</string>
<string name="screen_login_error_invalid_credentials">"Usuari i/o contrasenya incorrectes"</string>
<string name="screen_login_error_invalid_user_id">"Identificador d\'usuari invàlid. Format esperat: ‘@usuari:servidor.org’"</string>
<string name="screen_login_error_refresh_tokens">"Aquest servidor està configurat per utilitzar tokens d\'actualització, però no són compatibles quan s\'utilitza l\'inici de sessió basat en contrasenya."</string>
<string name="screen_login_error_unsupported_authentication">"El servidor seleccionat no admet contrasenya o inici de sessió OAuth. Posa\'t en contacte amb l\'administrador o tria un altre servidor."</string>
<string name="screen_login_form_header">"Introdueix les teves dades"</string>
<string name="screen_login_subtitle">"Matrix és una xarxa oberta per a comunicacions segures i descentralitzades."</string>
<string name="screen_login_title">"Hola de nou!"</string>
<string name="screen_login_title_with_homeserver">"Inicia sessió a %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Inicia sessió manualment"</string>
<string name="screen_onboarding_sign_in_to">"Inicia sessió a %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Inicia sessió amb un codi QR"</string>
<string name="screen_onboarding_sign_up">"Crea un compte"</string>
<string name="screen_onboarding_welcome_message">"Et donem la benvinguda a %1$s. Més ràpid i simple que mai."</string>
<string name="screen_onboarding_welcome_subtitle">"Et donem la benvinguda a %1$s. Dissenyat per ser més ràpid i simple."</string>
<string name="screen_onboarding_welcome_title">"Sigues el teu propi element"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Establint una connexió segura"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"No s\'ha pogut establir una connexió segura amb el dispositiu nou. Els dispositius existents continuen sent segurs, no te n\'has de preocupar."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"I ara què?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Prova de tornar a iniciar sessió mitjançant un codi QR si es tracta d\'un problema de xarxa."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Si es repeteix el mateix problema, prova una xarxa wifi diferent o utilitza les dades mòbils en lloc del wifi."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Si no funciona, inicia sessió manualment"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Connexió no segura"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Se\'t demanarà que introdueixis els dos dígits mostrats en aquest dispositiu."</string>
<string name="screen_qr_code_login_device_code_title">"Introdueix el número següent a l\'altre dispositiu"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Inicia sessió a l\'altre dispositiu i torna-ho a provar o utilitza un altre dispositiu amb la sessió ja iniciada."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"No s\'ha iniciat sessió a l\'altre dispositiu"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"L\'inici de sessió s\'ha cancel·lat a l\'altre dispositiu."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Sol·licitud d\'inici de sessió cancel·lada"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"L\'inici de sessió s\'ha rebutjat a l\'altre dispositiu."</string>
<string name="screen_qr_code_login_error_declined_title">"Inici de sessió rebutjat"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Inici de sessió ha caducat. Torna-ho a provar."</string>
<string name="screen_qr_code_login_error_expired_title">"L\'inici de sessió no s\'ha completat a temps"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"El teu altre dispositiu no admet l\'inici de sessió a %s amb codis QR.
Prova d\'iniciar la sessió manualment o escaneja el QR amb un altre dispositiu."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"Codi QR no compatible"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"El proveïdor del teu compte no admet %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s no és compatible"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Preparat per escanejar"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Obre %1$s en un dispositiu d\'escriptori"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Clica la teva imatge"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Selecciona %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Enllaça nou dispositiu”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Escaneja el codi QR amb aquest dispositiu"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Només disponible si el proveïdor del compte ho admet."</string>
<string name="screen_qr_code_login_initial_state_title">"Obre %1$s en un altre dispositiu per obtenir el codi QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Utilitza el codi QR que es mostra a l\'altre dispositiu."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Torna-ho a intentar"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Codi QR incorrecte"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Vés a la configuració de càmera"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Per continuar, has donar permís a %1$s per poder utilitzar la càmera del dispositiu."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Permet l\'accés a la càmera per poder escanejar el codi QR"</string>
<string name="screen_qr_code_login_scanning_state_title">"Escaneja el QR"</string>
<string name="screen_qr_code_login_start_over_button">"Torna a començar"</string>
<string name="screen_qr_code_login_unknown_error_description">"S\'ha produït un error inesperat. Torna-ho a provar."</string>
<string name="screen_qr_code_login_verify_code_loading">"Esperant el teu altre dispositiu"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Per verificar l\'inici de sessió, pot ser que el proveïdor del teu compte et demani el següent codi."</string>
<string name="screen_qr_code_login_verify_code_title">"Codi de verificació"</string>
<string name="screen_server_confirmation_change_server">"Canvia el proveïdor del compte"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Servidor privat per a treballadors d\'Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix és una xarxa oberta per a comunicacions segures i descentralitzades."</string>
<string name="screen_server_confirmation_message_register">"Aquí és on es guardaran els teus xats, de manera similar a utilitzar un proveïdor de correu electrònic per guardar els teus correus electrònics."</string>
<string name="screen_server_confirmation_title_login">"Estàs a punt d\'iniciar sessió a %1$s"</string>
<string name="screen_server_confirmation_title_register">"Estàs a punt de crear un compte a %1$s"</string>
</resources>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Mikä on palvelimesi osoite?"</string>
<string name="screen_change_server_title">"Valitse palvelimesi"</string>
<string name="screen_create_account_title">"Luo tili"</string>
<string name="screen_login_error_deactivated_account">"Tämä tili on deaktivoitu."</string>
<string name="screen_login_error_deactivated_account">"Tämä tili on poistettu."</string>
<string name="screen_login_error_invalid_credentials">"Väärä käyttäjänimi ja/tai salasana"</string>
<string name="screen_login_error_invalid_user_id">"Tämä ei ole kelvollinen käyttäjätunnus. Odotettu muoto: \'@käyttäjä:kotipalvelin.fi\'"</string>
<string name="screen_login_error_refresh_tokens">"Tämä palvelin on määritetty käyttämään refresh tokeneja. Näitä ei tueta salasanapohjaisen kirjautumisen kanssa."</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Какой адрес у вашего сервера?"</string>
<string name="screen_change_server_title">"Выберите свой сервер"</string>
<string name="screen_create_account_title">"Создать аккаунт"</string>
<string name="screen_login_error_deactivated_account">"Данная учётная запись была отключена."</string>
<string name="screen_login_error_deactivated_account">"Эта учетная запись была удалена."</string>
<string name="screen_login_error_invalid_credentials">"Неверное имя пользователя и/или пароль"</string>
<string name="screen_login_error_invalid_user_id">"Это некорректный идентификатор пользователя. Правильный формат: @user:homeserver.org"</string>
<string name="screen_login_error_refresh_tokens">"Этот сервер настроен на использование токенов обновления. Они не поддерживаются при использовании входа на основе пароля."</string>
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"更改账户提供方"</string>
<string name="screen_account_provider_change">"更改账户提供者"</string>
<string name="screen_account_provider_form_hint">"主服务器地址"</string>
<string name="screen_account_provider_form_notice">"输入搜索词或域名地址。"</string>
<string name="screen_account_provider_form_subtitle">"搜索公司、社区或私人服务器。"</string>
<string name="screen_account_provider_form_title">"寻找账户提供方"</string>
<string name="screen_account_provider_form_title">"查找账户提供者"</string>
<string name="screen_account_provider_signin_subtitle">"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"</string>
<string name="screen_account_provider_signin_title">"你即将登录到 %s"</string>
<string name="screen_account_provider_signup_subtitle">"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"</string>
@@ -12,7 +12,7 @@
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org 由 Matrix.org 基金会运营,是用于安全、去中心化的通信的公共 Matrix 网络上的大型免费服务器。"</string>
<string name="screen_change_account_provider_other">"其它"</string>
<string name="screen_change_account_provider_subtitle">"使用其它账户提供者,例如你自己的私有服务器或工作账户。"</string>
<string name="screen_change_account_provider_title">"更改账户提供方"</string>
<string name="screen_change_account_provider_title">"更改账户提供者"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s 要求 Element Pro。请从应用商店下载。"</string>
<string name="screen_change_server_error_element_pro_required_title">"需要 Element Pro"</string>
@@ -77,7 +77,7 @@
尝试手动或使用另一个设备扫描二维码."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"二维码不受支持"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"账户提供方不支持 %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"账户提供者不支持 %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"不支持 %1$s."</string>
<string name="screen_qr_code_login_initial_state_button_title">"准备进行扫描"</string>
<string name="screen_qr_code_login_initial_state_item_1">"在桌面设备上打开 %1$s"</string>
@@ -99,7 +99,7 @@
<string name="screen_qr_code_login_verify_code_loading">"正在等待其它设备"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"你的账户提供者可能会要求你提供以下代码以验证登录。"</string>
<string name="screen_qr_code_login_verify_code_title">"你的验证码"</string>
<string name="screen_server_confirmation_change_server">"更改账户提供方"</string>
<string name="screen_server_confirmation_change_server">"更改账户提供者"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"专为 Element 员工提供的私人服务器。"</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix 是一个用于安全、去中心化通信的开放网络。"</string>
<string name="screen_server_confirmation_message_register">"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"</string>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Segur que vols tancar sessió?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Tanca sessió"</string>
<string name="screen_signout_confirmation_dialog_title">"Tanca sessió"</string>
<string name="screen_signout_in_progress_dialog_content">"S\'està tancant la sessió…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Estàs a punt de tancar sessió. Si tanques sessió ara, perdràs l\'accés als missatges xifrats."</string>
<string name="screen_signout_key_backup_disabled_title">"Has desactivat la còpia de seguretat"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Encara tens una còpia de seguretat de les teves claus i t\'has desconnectat. Torna a connectar-te per poder fer una còpia de seguretat de les teves claus abans de tancar la sessió."</string>
<string name="screen_signout_key_backup_offline_title">"Encara s\'està fent una còpia de seguretat de les teves claus"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Espera a que s\'hagi completat abans de tancar sessió."</string>
<string name="screen_signout_key_backup_ongoing_title">"Encara s\'està fent una còpia de seguretat de les teves claus"</string>
<string name="screen_signout_preference_item">"Tanca sessió"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Estàs a punt de tancar sessió a la teva última i única sessió. Si tanques sessió ara, perdràs l\'accés als missatges xifrats."</string>
<string name="screen_signout_recovery_disabled_title">"Recuperació no configurada"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Estàs a punt de tancar sessió. Si tanques sessió ara, pot ser que perdis l\'accés als missatges xifrats."</string>
</resources>
+1
View File
@@ -70,6 +70,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)
@@ -550,7 +550,7 @@ class MessagesPresenter(
val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser)
val composerMode = MessageComposerMode.Reply(
replyToDetails = replyToDetails,
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId),
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId, targetEvent.isMine),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -412,7 +413,7 @@ fun MessagesView(
}
@Composable
internal fun MessagesMenuActions(
internal fun RowScope.MessagesMenuActions(
displayThreads: Boolean,
roomCallState: RoomCallState,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
@@ -249,7 +249,7 @@ private fun ActionListViewContent(
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
style = when {
action.destructive -> ListItemStyle.Destructive
else -> ListItemStyle.Primary
else -> ListItemStyle.Default
}
)
}
@@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize
@Immutable
sealed interface Attachment : Parcelable {
@Parcelize
data class Media(val localMedia: LocalMedia) : Attachment
data class Media(
val localMedia: LocalMedia,
// When true, the media was picked through the "Files" picker and should be
// uploaded without image recompression; videos still use the highest available
// / best-fit preset rather than an additional size-reduction optimization pass.
// See https://github.com/element-hq/element-x-android/issues/6365
val sendAsFile: Boolean = false,
) : Attachment
}

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