mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'release/26.06.0'
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
-->
|
||||
<paths>
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="notification_sounds" path="notification_sounds/" />
|
||||
</paths>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+5
@@ -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>
|
||||
+1
@@ -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
@@ -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>
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-6
@@ -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>
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+18
-11
@@ -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,
|
||||
)
|
||||
|
||||
+13
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -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(),
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+5
-4
@@ -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>
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
+2
-4
@@ -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>
|
||||
+8
-2
@@ -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
@@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@ private fun DoneButton(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Buttons(
|
||||
private fun ColumnScope.Buttons(
|
||||
onRetry: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
|
||||
+6
-1
@@ -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,
|
||||
)
|
||||
|
||||
+87
@@ -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
|
||||
}
|
||||
}
|
||||
+14
@@ -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>,
|
||||
)
|
||||
+27
@@ -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,
|
||||
)
|
||||
+45
-8
@@ -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>
|
||||
|
||||
+100
@@ -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,
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ class ShowQrCodeViewTest {
|
||||
) {
|
||||
setContent {
|
||||
ShowQrCodeView(
|
||||
data = "DATA",
|
||||
state = aShowQrCodeState(),
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
|
||||
+13
-3
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+129
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+42
@@ -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) }
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+21
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-32
@@ -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)
|
||||
}
|
||||
|
||||
+5
@@ -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)")
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
|
||||
+11
@@ -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()
|
||||
|
||||
+24
-14
@@ -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)
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+8
-5
@@ -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
|
||||
)
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+2
@@ -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>,
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+7
-3
@@ -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>
|
||||
|
||||
+13
-6
@@ -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
|
||||
}
|
||||
|
||||
+52
-44
@@ -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(
|
||||
|
||||
+2
@@ -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(),
|
||||
)
|
||||
},
|
||||
|
||||
+28
-1
@@ -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>
|
||||
+1
-1
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+8
-1
@@ -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
Reference in New Issue
Block a user