diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 2307ea6e3e..ea44e3a5d0 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,12 +1,5 @@
-## Type of change
-
-- [ ] Feature
-- [ ] Bugfix
-- [ ] Technical
-- [ ] Other :
-
## Content
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index 7547d5291c..602b43a7e1 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -17,7 +17,7 @@ jobs:
name: Build Enterprise APKs
runs-on: ubuntu-latest
# Skip in forks
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
strategy:
matrix:
variant: [debug, release, nightly]
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index b4fd79e5dd..6b6ac8243e 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -6,6 +6,8 @@ jobs:
build:
runs-on: ubuntu-latest
name: Danger main check
+ # Skip in forks, it doesn't work even with the fallback token
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v4
- name: Add SSH private keys for submodule repositories
@@ -13,7 +15,7 @@ jobs:
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index 96f64eb1f7..46494ba76a 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -9,7 +9,7 @@ jobs:
generate-github-pages:
runs-on: ubuntu-latest
# Skip in forks
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml
index b3493e3bf7..d35bace705 100644
--- a/.github/workflows/gradle-wrapper-update.yml
+++ b/.github/workflows/gradle-wrapper-update.yml
@@ -12,7 +12,7 @@ jobs:
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1
# Skip in forks
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
target-branch: develop
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index ff435ee988..a0643c426f 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -20,10 +20,11 @@ jobs:
- uses: actions/checkout@v4
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Run code quality check suite
run: ./tools/check/check_code_quality.sh
@@ -77,10 +78,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 17
uses: actions/setup-java@v4
@@ -116,10 +118,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 17
uses: actions/setup-java@v4
@@ -159,10 +162,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 17
uses: actions/setup-java@v4
@@ -198,10 +202,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 17
uses: actions/setup-java@v4
@@ -237,10 +242,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 17
uses: actions/setup-java@v4
@@ -271,6 +277,7 @@ jobs:
name: Project Check Suite
runs-on: ubuntu-latest
needs: [konsist, lint, ktlint, detekt]
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v4
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b58f2f3348..38be804fc1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -42,6 +42,7 @@ jobs:
enterprise:
name: Create App Bundle Enterprise
runs-on: ubuntu-latest
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
concurrency:
group: ${{ format('build-release-main-gplay-{0}', github.sha) }}
cancel-in-progress: true
@@ -49,6 +50,7 @@ jobs:
- uses: actions/checkout@v4
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 347923a6c0..5f99cac17a 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -9,7 +9,7 @@ jobs:
sync-localazy:
runs-on: ubuntu-latest
# Skip in forks
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v4
- name: Use JDK 17
diff --git a/.github/workflows/sync-sas-strings.yml b/.github/workflows/sync-sas-strings.yml
index f9bff2905b..d06cfe268a 100644
--- a/.github/workflows/sync-sas-strings.yml
+++ b/.github/workflows/sync-sas-strings.yml
@@ -9,7 +9,7 @@ jobs:
sync-sas-strings:
runs-on: ubuntu-latest
# Skip in forks
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
# No concurrency required, runs every time on a schedule.
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a132fd7d14..fa274baf89 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -40,10 +40,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
- if: github.repository == 'element-hq/element-x-android'
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: ☕️ Use JDK 17
uses: actions/setup-java@v4
diff --git a/CHANGES.md b/CHANGES.md
index 4046f06e0b..c2c2641b34 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,77 @@
+Changes in Element X v0.4.16 (2024-07-05)
+=========================================
+
+### ✨ Features
+* Avatar cluster for DM by @bmarty in https://github.com/element-hq/element-x-android/pull/3069
+* Feature : Draft support by @ganfra in https://github.com/element-hq/element-x-android/pull/3099
+* Timeline : re-enable edition of local echo by @ganfra in https://github.com/element-hq/element-x-android/pull/3126
+* Draft : add volatile storage when moving to edit mode. by @ganfra in https://github.com/element-hq/element-x-android/pull/3132
+
+### 🙌 Improvements
+* Give locale and theme to Element Call by @bmarty in https://github.com/element-hq/element-x-android/pull/3118
+* Let the SDK retrieve and parse Element well known content by @bmarty in https://github.com/element-hq/element-x-android/pull/3127
+
+### 🐛 Bugfixes
+* Let role and permissions screens works for invited room members too. by @bmarty in https://github.com/element-hq/element-x-android/pull/3081
+* Fix image rendering after clear cache by @bmarty in https://github.com/element-hq/element-x-android/pull/3082
+* Replace the 'answer' PendingIntent in ringing call notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3093
+* Use IO dispatcher for cleanup in bug reporter by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3092
+* Fix `@room` mentions crashing in debug builds by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3107
+* Auth : fix restore session when there is no network. by @ganfra in https://github.com/element-hq/element-x-android/pull/3109
+* Alert for incoming call even if notifications are disabled - WAITING FOR FINAL PRODUCT DECISION by @bmarty in https://github.com/element-hq/element-x-android/pull/3053
+* Fix incorrect 'device verified' screen when app was opened with no network connection by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3110
+* Draft : also clear draft when composer is blank by @ganfra in https://github.com/element-hq/element-x-android/pull/3115
+* Timeline : fix text item not refreshed when content change by @ganfra in https://github.com/element-hq/element-x-android/pull/3123
+* FFs can now be toggled in release builds too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3101
+* Fix crash when getting the system ringtone for ringing calls by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3131
+* Bugfix : avoid potential NPE on verification service. by @ganfra in https://github.com/element-hq/element-x-android/pull/3140
+
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3114
+* Sync Strings - Add Greek translations by @ElementBot in https://github.com/element-hq/element-x-android/pull/3133
+
+### 🧱 Build
+* Let GitHub generates the release notes by @bmarty in https://github.com/element-hq/element-x-android/pull/3105
+* Fix F-Droid reproducible build. by @bmarty in https://github.com/element-hq/element-x-android/pull/3106
+* Element enterprise (EE) foundations by @bmarty in https://github.com/element-hq/element-x-android/pull/3025
+* Fix Element Enterprise nightly build and publication using App Distribution by @bmarty in https://github.com/element-hq/element-x-android/pull/3130
+* Improve screenshot testing with ComposablePreviewScanner by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3125
+
+### Dependency upgrades
+* Update dependency com.posthog:posthog-android to v3.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3060
+* Update danger/danger-js action to v12.3.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3059
+* Update dependency com.freeletics.flowredux:compose to v1.2.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3067
+* Update dependency com.google.firebase:firebase-bom to v33.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3062
+* Update dependency androidx.test.ext:junit to v1.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3088
+* Update test.core to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3090
+* Remove dependencies androidx.test.espresso:espresso-core and androidx.appcompat:appcompat by @renovate in https://github.com/element-hq/element-x-android/pull/3087
+* Update wysiwyg to v2.37.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3094
+* Update dependency androidx.test:runner to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3089
+* Update test.core to v1.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3104
+* Update dependency androidx.test:runner to v1.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3103
+* Update dependency androidx.test.ext:junit to v1.2.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3102
+* Update dependency com.google.truth:truth to v1.4.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3108
+* Update dependency com.posthog:posthog-android to v3.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3111
+* Update dependency io.nlopez.compose.rules:detekt to v0.4.5 by @renovate in https://github.com/element-hq/element-x-android/pull/3116
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.29 by @renovate in https://github.com/element-hq/element-x-android/pull/3119
+* Update plugin dependencycheck to v10 by @renovate in https://github.com/element-hq/element-x-android/pull/3128
+* Update plugin dependencycheck to v10.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3129
+* Update dependency io.sentry:sentry-android to v7.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3122
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.30 by @renovate in https://github.com/element-hq/element-x-android/pull/3138
+
+### Others
+* Feature/fga/sending queue iteration by @ganfra in https://github.com/element-hq/element-x-android/pull/3054
+* Use full date format for day dividers in timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3057
+* Let Dms use other member color. by @bmarty in https://github.com/element-hq/element-x-android/pull/3058
+* Resolve display names in mentions in real time by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3051
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3077
+* Improve the way we cut the bubble layout to give space for the sender Avatar by @bmarty in https://github.com/element-hq/element-x-android/pull/3080
+* Upgrade build tools and fix `pg-map-id` for F-Droid by @bmarty in https://github.com/element-hq/element-x-android/pull/3084
+* Improve room filtering behavior. by @bmarty in https://github.com/element-hq/element-x-android/pull/3083
+* Adapt our code to the new authentication APIs in the Rust SDK by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3068
+* Add temporary icon for Element Enterprise by @bmarty in https://github.com/element-hq/element-x-android/pull/3134
+* Improve click behavior on room timeline title by @bmarty in https://github.com/element-hq/element-x-android/pull/3064
+
Changes in Element X v0.4.15 (2024-06-19)
=========================================
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3f5618b208..7778a3246e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -36,6 +36,7 @@ plugins {
id(libs.plugins.firebaseAppDistribution.get().pluginId)
alias(libs.plugins.knit)
id("kotlin-parcelize")
+ id("com.google.android.gms.oss-licenses-plugin")
// To be able to update the firebase.xml files, uncomment and build the project
// id("com.google.gms.google-services")
}
@@ -250,6 +251,7 @@ dependencies {
implementation(projects.anvilannotations)
implementation(projects.appnav)
implementation(projects.appconfig)
+ implementation(projects.libraries.uiStrings)
anvil(projects.anvilcodegen)
// Comment to not include firebase in the project
@@ -257,6 +259,8 @@ dependencies {
// Comment to not include unified push in the project
implementation(projects.libraries.pushproviders.unifiedpush)
+ "gplayImplementation"(libs.play.services.oss.licenses)
+
implementation(libs.appyx.core)
implementation(libs.androidx.splash)
implementation(libs.androidx.core)
diff --git a/app/src/fdroid/kotlin/io/element/android/x/licenses/FdroidOpenSourceLicensesProvider.kt b/app/src/fdroid/kotlin/io/element/android/x/licenses/FdroidOpenSourceLicensesProvider.kt
new file mode 100644
index 0000000000..2e926f487e
--- /dev/null
+++ b/app/src/fdroid/kotlin/io/element/android/x/licenses/FdroidOpenSourceLicensesProvider.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.x.licenses
+
+import android.app.Activity
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.preferences.api.OpenSourceLicensesProvider
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class FdroidOpenSourceLicensesProvider @Inject constructor() : OpenSourceLicensesProvider {
+ override val hasOpenSourceLicenses: Boolean = false
+
+ override fun navigateToOpenSourceLicenses(activity: Activity) {
+ error("Not supported, please ensure that hasOpenSourcesLicenses is true before calling this method")
+ }
+}
diff --git a/app/src/gplay/AndroidManifest.xml b/app/src/gplay/AndroidManifest.xml
new file mode 100644
index 0000000000..234003d953
--- /dev/null
+++ b/app/src/gplay/AndroidManifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/gplay/kotlin/io/element/android/x/licenses/OssOpenSourcesLicensesProvider.kt b/app/src/gplay/kotlin/io/element/android/x/licenses/OssOpenSourcesLicensesProvider.kt
new file mode 100644
index 0000000000..93848c438d
--- /dev/null
+++ b/app/src/gplay/kotlin/io/element/android/x/licenses/OssOpenSourcesLicensesProvider.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.x.licenses
+
+import android.app.Activity
+import android.content.Intent
+import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.preferences.api.OpenSourceLicensesProvider
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.ui.strings.CommonStrings
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class OssOpenSourcesLicensesProvider @Inject constructor() : OpenSourceLicensesProvider {
+ override val hasOpenSourceLicenses: Boolean = true
+
+ override fun navigateToOpenSourceLicenses(activity: Activity) {
+ val title = activity.getString(CommonStrings.common_open_source_licenses)
+ OssLicensesMenuActivity.setActivityTitle(title)
+ activity.startActivity(Intent(activity, OssLicensesMenuActivity::class.java))
+ }
+}
diff --git a/app/src/gplay/res/values-night/colors.xml b/app/src/gplay/res/values-night/colors.xml
new file mode 100644
index 0000000000..e830bc1cb1
--- /dev/null
+++ b/app/src/gplay/res/values-night/colors.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ #FF101317
+
+ #FFEBEEF2
+
+ #ff808994
+
+ #FF4187EB
+
+ false
+ false
+
+
diff --git a/app/src/gplay/res/values-v27/themes.xml b/app/src/gplay/res/values-v27/themes.xml
new file mode 100644
index 0000000000..99fe605d0a
--- /dev/null
+++ b/app/src/gplay/res/values-v27/themes.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/gplay/res/values/colors.xml b/app/src/gplay/res/values/colors.xml
new file mode 100644
index 0000000000..04f5f153d1
--- /dev/null
+++ b/app/src/gplay/res/values/colors.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ #FFFFFFFF
+
+ #FF1B1D22
+
+ #FF656D77
+
+ #FF0467DD
+
+ true
+ true
+
+
diff --git a/app/src/gplay/res/values/styles.xml b/app/src/gplay/res/values/styles.xml
new file mode 100644
index 0000000000..1e8c836919
--- /dev/null
+++ b/app/src/gplay/res/values/styles.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/app/src/gplay/res/values/themes.xml b/app/src/gplay/res/values/themes.xml
new file mode 100644
index 0000000000..95f0429e0d
--- /dev/null
+++ b/app/src/gplay/res/values/themes.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
index eb3f9450f5..b36f93f1dc 100644
--- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt
+++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
@@ -26,9 +26,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -38,16 +35,13 @@ import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.compound.theme.Theme
-import io.element.android.compound.theme.isDark
-import io.element.android.compound.theme.mapToTheme
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.api.handleSecureFlag
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.x.di.AppBindings
import io.element.android.x.intent.SafeUriHandler
@@ -74,14 +68,8 @@ class MainActivity : NodeActivity() {
@Composable
private fun MainContent(appBindings: AppBindings) {
- val theme by remember {
- appBindings.preferencesStore().getThemeFlow().mapToTheme()
- }
- .collectAsState(initial = Theme.System)
val migrationState = appBindings.migrationEntryPoint().present()
- ElementTheme(
- darkTheme = theme.isDark()
- ) {
+ ElementThemeApp(appBindings.preferencesStore()) {
CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this),
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
index 7c94af4619..e310a61147 100644
--- a/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
+++ b/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt
@@ -71,8 +71,7 @@ class TracingInitializer : Initializer {
return WriteToFilesConfiguration.Enabled(
directory = bugReporter.logDirectory().absolutePath,
filenamePrefix = "logs",
- filenameSuffix = null,
- // Keep a minimum of 1 week of log files.
+ // Keep a maximum of 1 week of log files.
numberOfFiles = 7 * 24,
)
}
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index 2884d43c88..fa0e1e2199 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -13,7 +13,9 @@
+
+
diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts
index a6e66f1462..bb3f16e3b4 100644
--- a/appconfig/build.gradle.kts
+++ b/appconfig/build.gradle.kts
@@ -15,20 +15,13 @@
*/
plugins {
id("io.element.android-library")
- alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.appconfig"
}
-anvil {
- generateDaggerFactories.set(true)
-}
-
dependencies {
implementation(libs.androidx.annotationjvm)
- implementation(libs.dagger)
- implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt
index 02586bea0d..0cfe2bd7a5 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt
@@ -16,44 +16,28 @@
package io.element.android.appconfig
-import com.squareup.anvil.annotations.ContributesTo
-import dagger.Module
-import dagger.Provides
-import io.element.android.libraries.di.AppScope
import kotlin.time.Duration
-import kotlin.time.Duration.Companion.seconds
-
-/**
- * Configuration for the lock screen feature.
- * @property isPinMandatory Whether the PIN is mandatory or not.
- * @property pinBlacklist Some PINs are forbidden.
- * @property pinSize The size of the PIN.
- * @property maxPinCodeAttemptsBeforeLogout Number of attempts before the user is logged out.
- * @property gracePeriod Time period before locking the app once backgrounded.
- * @property isStrongBiometricsEnabled Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported.
- * @property isWeakBiometricsEnabled Authentication with weak methods (most face/iris unlock implementations) is supported.
- */
-data class LockScreenConfig(
- val isPinMandatory: Boolean,
- val pinBlacklist: Set,
- val pinSize: Int,
- val maxPinCodeAttemptsBeforeLogout: Int,
- val gracePeriod: Duration,
- val isStrongBiometricsEnabled: Boolean,
- val isWeakBiometricsEnabled: Boolean,
-)
-
-@ContributesTo(AppScope::class)
-@Module
-object LockScreenConfigModule {
- @Provides
- fun providesLockScreenConfig(): LockScreenConfig = LockScreenConfig(
- isPinMandatory = false,
- pinBlacklist = setOf("0000", "1234"),
- pinSize = 4,
- maxPinCodeAttemptsBeforeLogout = 3,
- gracePeriod = 0.seconds,
- isStrongBiometricsEnabled = true,
- isWeakBiometricsEnabled = true,
- )
+import kotlin.time.Duration.Companion.minutes
+
+object LockScreenConfig {
+ /** Whether the PIN is mandatory or not. */
+ const val IS_PIN_MANDATORY: Boolean = false
+
+ /** Set of forbidden PIN codes. */
+ val FORBIDDEN_PIN_CODES: Set = setOf("0000", "1234")
+
+ /** The size of the PIN. */
+ const val PIN_SIZE: Int = 4
+
+ /** Number of attempts before the user is logged out. */
+ const val MAX_PIN_CODE_ATTEMPTS_BEFORE_LOGOUT: Int = 3
+
+ /** Time period before locking the app once backgrounded. */
+ val GRACE_PERIOD: Duration = 2.minutes
+
+ /** Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported. */
+ const val IS_STRONG_BIOMETRICS_ENABLED: Boolean = true
+
+ /** Authentication with weak methods (most face/iris unlock implementations) is supported. */
+ const val IS_WEAK_BIOMETRICS_ENABLED: Boolean = true
}
diff --git a/appicon/enterprise/src/main/res/mipmap-hdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-hdpi/ic_launcher_background_enterprise.webp
index f051ae3c81..45f7756748 100644
Binary files a/appicon/enterprise/src/main/res/mipmap-hdpi/ic_launcher_background_enterprise.webp and b/appicon/enterprise/src/main/res/mipmap-hdpi/ic_launcher_background_enterprise.webp differ
diff --git a/appicon/enterprise/src/main/res/mipmap-mdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-mdpi/ic_launcher_background_enterprise.webp
index 27d9d1db19..937dbf1c5d 100644
Binary files a/appicon/enterprise/src/main/res/mipmap-mdpi/ic_launcher_background_enterprise.webp and b/appicon/enterprise/src/main/res/mipmap-mdpi/ic_launcher_background_enterprise.webp differ
diff --git a/appicon/enterprise/src/main/res/mipmap-xhdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xhdpi/ic_launcher_background_enterprise.webp
index 4dbc6db066..f73e1d5ff4 100644
Binary files a/appicon/enterprise/src/main/res/mipmap-xhdpi/ic_launcher_background_enterprise.webp and b/appicon/enterprise/src/main/res/mipmap-xhdpi/ic_launcher_background_enterprise.webp differ
diff --git a/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_background_enterprise.webp
index b635d5cbb5..65027bb480 100644
Binary files a/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_background_enterprise.webp and b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_background_enterprise.webp differ
diff --git a/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_enterprise.webp
index 2250307a03..90b491a343 100644
Binary files a/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_enterprise.webp and b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_enterprise.webp differ
diff --git a/appicon/enterprise/src/main/res/mipmap-xxxhdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xxxhdpi/ic_launcher_background_enterprise.webp
index b5cb68c7bb..7ae770c812 100644
Binary files a/appicon/enterprise/src/main/res/mipmap-xxxhdpi/ic_launcher_background_enterprise.webp and b/appicon/enterprise/src/main/res/mipmap-xxxhdpi/ic_launcher_background_enterprise.webp differ
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index e99f3e01f0..53680fd44e 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.deeplink)
implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
index 915001c919..d181b22bed 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
@@ -104,11 +104,6 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
inputs.room.subscribeToSync()
}
},
- onPause = {
- appCoroutineScope.launch {
- inputs.room.unsubscribeFromSync()
- }
- },
onDestroy = {
Timber.v("OnDestroy")
appNavigationStateService.onLeavingRoom(id)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
index b801415f88..9bdd203cd5 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
@@ -23,6 +23,7 @@ import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
+import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -42,6 +43,7 @@ class RootNavStateFlowFactory @Inject constructor(
private val matrixClientsHolder: MatrixClientsHolder,
private val imageLoaderHolder: ImageLoaderHolder,
private val loginUserStory: LoginUserStory,
+ private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory,
) {
private var currentCacheIndex = 0
@@ -73,6 +75,8 @@ class RootNavStateFlowFactory @Inject constructor(
matrixClientsHolder.remove(sessionId)
// Ensure image loader will be recreated with the new MatrixClient
imageLoaderHolder.remove(sessionId)
+ // Also remove cached value for SessionPreferencesStore
+ sessionPreferencesStoreFactory.remove(sessionId)
}
.toIndexFlow(initialCacheIndex)
.onEach { cacheIndex ->
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
index d307f851cd..b1009f20da 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt
@@ -123,7 +123,9 @@ class JoinRoomLoadedFlowNodeTest {
@Test
fun `given a room flow node when initialized then it loads messages entry point`() = runTest {
// GIVEN
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ updateMembersResult = { }
+ )
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
@@ -144,7 +146,9 @@ class JoinRoomLoadedFlowNodeTest {
@Test
fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest {
// GIVEN
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ updateMembersResult = { }
+ )
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt
index bb4e8f3dbf..91f0aee517 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt
@@ -33,7 +33,6 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) class SendQueuesTest {
private val matrixClient = FakeMatrixClient()
- private val room = FakeMatrixRoom()
private val networkMonitor = FakeNetworkMonitor()
private val sut = SendQueues(matrixClient, networkMonitor)
@@ -43,11 +42,11 @@ import org.junit.Test
val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> }
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
- matrixClient.givenGetRoomResult(room.roomId, room)
-
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
- room.setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
-
+ val room = FakeMatrixRoom(
+ setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
+ )
+ matrixClient.givenGetRoomResult(room.roomId, room)
sut.launchIn(backgroundScope)
sendQueueDisabledFlow.emit(room.roomId)
@@ -72,10 +71,11 @@ import org.junit.Test
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
networkMonitor.connectivity.value = NetworkStatus.Offline
- matrixClient.givenGetRoomResult(room.roomId, room)
-
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
- room.setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
+ val room = FakeMatrixRoom(
+ setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
+ )
+ matrixClient.givenGetRoomResult(room.roomId, room)
sut.launchIn(backgroundScope)
diff --git a/build.gradle.kts b/build.gradle.kts
index ff5d46ab12..8139ed6eb8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,6 +5,7 @@ buildscript {
dependencies {
classpath(libs.kotlin.gradle.plugin)
classpath(libs.gms.google.services)
+ classpath(libs.oss.licenses.plugin)
}
}
diff --git a/changelog.d/.gitignore b/changelog.d/.gitignore
deleted file mode 100644
index b722e9e13e..0000000000
--- a/changelog.d/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-!.gitignore
\ No newline at end of file
diff --git a/changelog.d/2869.feature b/changelog.d/2869.feature
deleted file mode 100644
index bfda19096b..0000000000
--- a/changelog.d/2869.feature
+++ /dev/null
@@ -1 +0,0 @@
-Store and restore drafts for each room.
diff --git a/changelog.d/2916.misc b/changelog.d/2916.misc
deleted file mode 100644
index b87146dd58..0000000000
--- a/changelog.d/2916.misc
+++ /dev/null
@@ -1 +0,0 @@
-Use a more natural date format for day dividers in the timeline. Also improve the time format for last messages in the room list.
diff --git a/changelog.d/3051.misc b/changelog.d/3051.misc
deleted file mode 100644
index 032d0c8dbe..0000000000
--- a/changelog.d/3051.misc
+++ /dev/null
@@ -1 +0,0 @@
-Resolve display names in mentions in real time, also send mentions with user ids as the fallback text for the link representation of the mentions.
diff --git a/changelog.d/3053.misc b/changelog.d/3053.misc
deleted file mode 100644
index 6a22cc363a..0000000000
--- a/changelog.d/3053.misc
+++ /dev/null
@@ -1 +0,0 @@
-Alert for incoming call even if notifications are disabled
diff --git a/changelog.d/3068.misc b/changelog.d/3068.misc
deleted file mode 100644
index add7772252..0000000000
--- a/changelog.d/3068.misc
+++ /dev/null
@@ -1 +0,0 @@
-Updated Rust SDK to `v0.2.28`. Fixed incompatibilities.
diff --git a/changelog.d/3073.bugfix b/changelog.d/3073.bugfix
deleted file mode 100644
index 2f7dbf19f9..0000000000
--- a/changelog.d/3073.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix feature flags not being able to be toggle in developer settings in release builds.
diff --git a/changelog.d/3081.bugfix b/changelog.d/3081.bugfix
deleted file mode 100644
index 37baf6cca2..0000000000
--- a/changelog.d/3081.bugfix
+++ /dev/null
@@ -1 +0,0 @@
- Let roles and permissions screens work for invited room members too.
diff --git a/changelog.d/3082.bugfix b/changelog.d/3082.bugfix
deleted file mode 100644
index 248b35ad7f..0000000000
--- a/changelog.d/3082.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix image rendering after clear cache
diff --git a/changelog.d/3083.bugfix b/changelog.d/3083.bugfix
deleted file mode 100644
index e3dfaf1da0..0000000000
--- a/changelog.d/3083.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Improve room filters behavior
diff --git a/changelog.d/3085.bugfix b/changelog.d/3085.bugfix
deleted file mode 100644
index bfad218bd2..0000000000
--- a/changelog.d/3085.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Make sure we replace the 'answer call' pending intent on ringing call notifications.
diff --git a/changelog.d/3086.bugfix b/changelog.d/3086.bugfix
deleted file mode 100644
index da39ba616f..0000000000
--- a/changelog.d/3086.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Make sure we don't use the main dispatcher while closing the bug report request, as it can lead to crashes in strict mode.
diff --git a/fastlane/metadata/android/en-US/changelogs/40005000.txt b/fastlane/metadata/android/en-US/changelogs/40005000.txt
new file mode 100644
index 0000000000..dd8c30a1b9
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40005000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: mostly bug fixes and performance improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/features/analytics/api/src/main/res/values-pl/translations.xml b/features/analytics/api/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..26aa9c6073
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-pl/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."
+ "Możesz przeczytać wszystkie nasze warunki %1$s."
+ "tutaj"
+ "Udostępniaj dane analityczne"
+
diff --git a/features/analytics/api/src/main/res/values-pt-rBR/translations.xml b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..4e7dc9775f
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Compartilhe dados de uso anônimos para nos ajudar a identificar problemas."
+ "Você pode ler todos os nossos termos %1$s ."
+ "aqui"
+ "Compartilhar dados de utilização"
+
diff --git a/features/analytics/impl/src/main/res/values-pl/translations.xml b/features/analytics/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..99e3e441bb
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Nie będziemy rejestrować ani profilować żadnych danych osobistych"
+ "Udostępniaj anonimowe dane dotyczące użytkowania, aby pomóc nam identyfikować problemy."
+ "Możesz przeczytać wszystkie nasze warunki %1$s."
+ "tutaj"
+ "Możesz to wyłączyć w dowolnym momencie"
+ "Nie będziemy udostępniać Twoich danych podmiotom trzecim"
+ "Pomóż nam ulepszyć %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..b2fe6e00bc
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Não registraremos nem criaremos perfil baseado em qualquer dado pessoal"
+ "Compartilhe dados de uso anônimos para nos ajudar a identificar problemas."
+ "Você pode ler todos os nossos termos %1$s ."
+ "aqui"
+ "Você pode desativar isso a qualquer momento"
+ "Não compartilharemos seus dados com terceiros"
+ "Ajude a melhorar o %1$s"
+
diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts
index d6d46d06b0..50c76b3b55 100644
--- a/features/call/impl/build.gradle.kts
+++ b/features/call/impl/build.gradle.kts
@@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.network)
@@ -70,4 +71,6 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/features/call/impl/src/main/AndroidManifest.xml b/features/call/impl/src/main/AndroidManifest.xml
index 354ea7533d..bdd88cf47a 100644
--- a/features/call/impl/src/main/AndroidManifest.xml
+++ b/features/call/impl/src/main/AndroidManifest.xml
@@ -38,10 +38,11 @@
@@ -77,10 +78,11 @@
-
@@ -90,9 +92,10 @@
android:exported="false"
android:foregroundServiceType="phoneCall" />
-
+
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
new file mode 100644
index 0000000000..da3c08da32
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+sealed interface PictureInPictureEvents {
+ data object EnterPictureInPicture : PictureInPictureEvents
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
new file mode 100644
index 0000000000..2c974382d0
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+import android.app.Activity
+import android.app.PictureInPictureParams
+import android.os.Build
+import android.util.Rational
+import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.log.logger.LoggerTag
+import timber.log.Timber
+import java.lang.ref.WeakReference
+import javax.inject.Inject
+
+private val loggerTag = LoggerTag("PiP")
+
+class PictureInPicturePresenter @Inject constructor(
+ pipSupportProvider: PipSupportProvider,
+) : Presenter {
+ private val isPipSupported = pipSupportProvider.isPipSupported()
+ private var isInPictureInPicture = mutableStateOf(false)
+ private var hostActivity: WeakReference? = null
+
+ @Composable
+ override fun present(): PictureInPictureState {
+ fun handleEvent(event: PictureInPictureEvents) {
+ when (event) {
+ PictureInPictureEvents.EnterPictureInPicture -> switchToPip()
+ }
+ }
+
+ return PictureInPictureState(
+ supportPip = isPipSupported,
+ isInPictureInPicture = isInPictureInPicture.value,
+ eventSink = ::handleEvent,
+ )
+ }
+
+ fun onCreate(activity: Activity) {
+ if (isPipSupported) {
+ Timber.tag(loggerTag.value).d("onCreate: Setting PiP params")
+ hostActivity = WeakReference(activity)
+ hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams())
+ } else {
+ Timber.tag(loggerTag.value).d("onCreate: PiP is not supported")
+ }
+ }
+
+ fun onDestroy() {
+ Timber.tag(loggerTag.value).d("onDestroy")
+ hostActivity?.clear()
+ hostActivity = null
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun getPictureInPictureParams(): PictureInPictureParams {
+ return PictureInPictureParams.Builder()
+ // Portrait for calls seems more appropriate
+ .setAspectRatio(Rational(3, 5))
+ .apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ setAutoEnterEnabled(true)
+ }
+ }
+ .build()
+ }
+
+ /**
+ * Enters Picture-in-Picture mode.
+ */
+ private fun switchToPip() {
+ if (isPipSupported) {
+ Timber.tag(loggerTag.value).d("Switch to PiP mode")
+ hostActivity?.get()?.enterPictureInPictureMode(getPictureInPictureParams())
+ ?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
+ }
+ }
+
+ fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
+ Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: $isInPictureInPictureMode")
+ isInPictureInPicture.value = isInPictureInPictureMode
+ }
+
+ fun onUserLeaveHint() {
+ Timber.tag(loggerTag.value).d("onUserLeaveHint")
+ switchToPip()
+ }
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt
new file mode 100644
index 0000000000..e6b86c82f0
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+data class PictureInPictureState(
+ val supportPip: Boolean,
+ val isInPictureInPicture: Boolean,
+ val eventSink: (PictureInPictureEvents) -> Unit,
+)
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
new file mode 100644
index 0000000000..8270e27e7d
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class PictureInPictureStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aPictureInPictureState(supportPip = true),
+ aPictureInPictureState(supportPip = true, isInPictureInPicture = true),
+ )
+}
+
+fun aPictureInPictureState(
+ supportPip: Boolean = false,
+ isInPictureInPicture: Boolean = false,
+ eventSink: (PictureInPictureEvents) -> Unit = {},
+): PictureInPictureState {
+ return PictureInPictureState(
+ supportPip = supportPip,
+ isInPictureInPicture = isInPictureInPicture,
+ eventSink = eventSink,
+ )
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt
new file mode 100644
index 0000000000..6cf3f080ad
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+interface PipSupportProvider {
+ @ChecksSdkIntAtLeast(Build.VERSION_CODES.O)
+ fun isPipSupported(): Boolean
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultPipSupportProvider @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val featureFlagService: FeatureFlagService,
+) : PipSupportProvider {
+ override fun isPipSupported(): Boolean {
+ val isSupportedByTheOs = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
+ context.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE).orFalse()
+ return if (isSupportedByTheOs) {
+ runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PictureInPicture) }
+ } else {
+ false
+ }
+ }
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
index be6622d8ee..ec30725fff 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
@@ -23,11 +23,12 @@ open class CallScreenStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aCallScreenState(),
+ aCallScreenState(urlState = AsyncData.Loading()),
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
)
}
-private fun aCallScreenState(
+internal fun aCallScreenState(
urlState: AsyncData = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
userAgent: String = "",
isInWidgetMode: Boolean = false,
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
index 23d0a4769e..70ab2e30c2 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
@@ -36,6 +36,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.impl.R
+import io.element.android.features.call.impl.pip.PictureInPictureEvents
+import io.element.android.features.call.impl.pip.PictureInPictureState
+import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
+import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog
@@ -58,25 +62,36 @@ interface CallScreenNavigator {
@Composable
internal fun CallScreenView(
state: CallScreenState,
+ pipState: PictureInPictureState,
requestPermissions: (Array, RequestPermissionCallback) -> Unit,
modifier: Modifier = Modifier,
) {
+ fun handleBack() {
+ if (pipState.supportPip) {
+ pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture)
+ } else {
+ state.eventSink(CallScreenEvents.Hangup)
+ }
+ }
+
Scaffold(
modifier = modifier,
topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.element_call)) },
- navigationIcon = {
- BackButton(
- imageVector = CompoundIcons.Close(),
- onClick = { state.eventSink(CallScreenEvents.Hangup) }
- )
- }
- )
+ if (!pipState.isInPictureInPicture) {
+ TopAppBar(
+ title = { Text(stringResource(R.string.element_call)) },
+ navigationIcon = {
+ BackButton(
+ imageVector = if (pipState.supportPip) CompoundIcons.ArrowLeft() else CompoundIcons.Close(),
+ onClick = ::handleBack,
+ )
+ }
+ )
+ }
}
) { padding ->
BackHandler {
- state.eventSink(CallScreenEvents.Hangup)
+ handleBack()
}
CallWebView(
modifier = Modifier
@@ -177,6 +192,19 @@ internal fun CallScreenViewPreview(
) = ElementPreview {
CallScreenView(
state = state,
+ pipState = aPictureInPictureState(),
+ requestPermissions = { _, _ -> },
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun CallScreenPipViewPreview(
+ @PreviewParameter(PictureInPictureStateProvider::class) state: PictureInPictureState,
+) = ElementPreview {
+ CallScreenView(
+ state = aCallScreenState(),
+ pipState = state,
requestPermissions = { _, _ -> },
)
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
index 1f4313864d..3af6c7cf95 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
@@ -30,28 +30,26 @@ import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.core.content.IntentCompat
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.compound.theme.Theme
-import io.element.android.compound.theme.isDark
-import io.element.android.compound.theme.mapToTheme
+import androidx.lifecycle.Lifecycle
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
+import io.element.android.features.call.impl.pip.PictureInPicturePresenter
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import timber.log.Timber
import javax.inject.Inject
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
+ @Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
private lateinit var presenter: CallScreenPresenter
@@ -67,6 +65,8 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
private var isDarkMode = false
private val webViewTarget = mutableStateOf(null)
+ private var eventSink: ((CallScreenEvents) -> Unit)? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -86,20 +86,19 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
updateUiMode(resources.configuration)
}
+ pictureInPicturePresenter.onCreate(this)
+
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
requestAudioFocus()
setContent {
- val theme by remember {
- appPreferencesStore.getThemeFlow().mapToTheme()
- }
- .collectAsState(initial = Theme.System)
- val state = presenter.present()
- ElementTheme(
- darkTheme = theme.isDark()
- ) {
+ val pipState = pictureInPicturePresenter.present()
+ ElementThemeApp(appPreferencesStore) {
+ val state = presenter.present()
+ eventSink = state.eventSink
CallScreenView(
state = state,
+ pipState = pipState,
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
@@ -114,6 +113,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
updateUiMode(newConfig)
}
+ override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
+ pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode)
+
+ if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ Timber.d("Exiting PiP mode: Hangup the call")
+ eventSink?.invoke(CallScreenEvents.Hangup)
+ }
+ }
+
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setCallType(intent)
@@ -131,10 +140,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
}
}
+ override fun onUserLeaveHint() {
+ super.onUserLeaveHint()
+ pictureInPicturePresenter.onUserLeaveHint()
+ }
+
override fun onDestroy() {
super.onDestroy()
releaseAudioFocus()
CallForegroundService.stop(this)
+ pictureInPicturePresenter.onDestroy()
}
override fun finish() {
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
index 02cd612bcf..05d36434cc 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
@@ -29,6 +29,8 @@ import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallState
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.designsystem.theme.ElementThemeApp
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -51,6 +53,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject
lateinit var activeCallManager: ActiveCallManager
+ @Inject
+ lateinit var appPreferencesStore: AppPreferencesStore
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -68,11 +73,13 @@ class IncomingCallActivity : AppCompatActivity() {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (notificationData != null) {
setContent {
- IncomingCallScreen(
- notificationData = notificationData,
- onAnswer = ::onAnswer,
- onCancel = ::onCancel,
- )
+ ElementThemeApp(appPreferencesStore) {
+ IncomingCallScreen(
+ notificationData = notificationData,
+ onAnswer = ::onAnswer,
+ onCancel = ::onCancel,
+ )
+ }
}
} else {
// No data, finish the activity
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt
index 80dc2353ca..d663f15807 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt
@@ -64,67 +64,65 @@ internal fun IncomingCallScreen(
onAnswer: (CallNotificationData) -> Unit,
onCancel: () -> Unit,
) {
- ElementTheme {
- OnboardingBackground()
+ OnboardingBackground()
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Bottom
+ ) {
Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Bottom
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 20.dp, end = 20.dp, top = 124.dp)
+ .weight(1f),
+ horizontalAlignment = Alignment.CenterHorizontally
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 20.dp, end = 20.dp, top = 124.dp)
- .weight(1f),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Avatar(
- avatarData = AvatarData(
- id = notificationData.senderId.value,
- name = notificationData.senderName,
- url = notificationData.avatarUrl,
- size = AvatarSize.IncomingCall,
- )
- )
- Spacer(modifier = Modifier.height(24.dp))
- Text(
- text = notificationData.senderName ?: notificationData.senderId.value,
- style = ElementTheme.typography.fontHeadingMdBold,
- textAlign = TextAlign.Center,
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = stringResource(R.string.screen_incoming_call_subtitle_android),
- style = ElementTheme.typography.fontBodyLgRegular,
- color = ElementTheme.colors.textSecondary,
- textAlign = TextAlign.Center,
- )
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- ActionButton(
- size = 64.dp,
- onClick = { onAnswer(notificationData) },
- icon = CompoundIcons.VoiceCall(),
- title = stringResource(CommonStrings.action_accept),
- backgroundColor = ElementTheme.colors.iconSuccessPrimary,
- borderColor = ElementTheme.colors.borderSuccessSubtle
+ Avatar(
+ avatarData = AvatarData(
+ id = notificationData.senderId.value,
+ name = notificationData.senderName,
+ url = notificationData.avatarUrl,
+ size = AvatarSize.IncomingCall,
)
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = notificationData.senderName ?: notificationData.senderId.value,
+ style = ElementTheme.typography.fontHeadingMdBold,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.screen_incoming_call_subtitle_android),
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = ElementTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ActionButton(
+ size = 64.dp,
+ onClick = { onAnswer(notificationData) },
+ icon = CompoundIcons.VoiceCall(),
+ title = stringResource(CommonStrings.action_accept),
+ backgroundColor = ElementTheme.colors.iconSuccessPrimary,
+ borderColor = ElementTheme.colors.borderSuccessSubtle
+ )
- ActionButton(
- size = 64.dp,
- onClick = onCancel,
- icon = CompoundIcons.EndCall(),
- title = stringResource(CommonStrings.action_reject),
- backgroundColor = ElementTheme.colors.iconCriticalPrimary,
- borderColor = ElementTheme.colors.borderCriticalSubtle
- )
- }
+ ActionButton(
+ size = 64.dp,
+ onClick = onCancel,
+ icon = CompoundIcons.EndCall(),
+ title = stringResource(CommonStrings.action_reject),
+ backgroundColor = ElementTheme.colors.iconCriticalPrimary,
+ borderColor = ElementTheme.colors.borderCriticalSubtle
+ )
}
}
}
@@ -145,7 +143,8 @@ private fun ActionButton(
horizontalAlignment = Alignment.CenterHorizontally
) {
FilledIconButton(
- modifier = Modifier.size(size + borderSize)
+ modifier = Modifier
+ .size(size + borderSize)
.border(borderSize, borderColor, CircleShape),
onClick = onClick,
colors = IconButtonDefaults.filledIconButtonColors(
@@ -171,22 +170,20 @@ private fun ActionButton(
@PreviewsDayNight
@Composable
-internal fun IncomingCallScreenPreview() {
- ElementPreview {
- IncomingCallScreen(
- notificationData = CallNotificationData(
- sessionId = SessionId("@alice:matrix.org"),
- roomId = RoomId("!1234:matrix.org"),
- eventId = EventId("\$asdadadsad:matrix.org"),
- senderId = UserId("@bob:matrix.org"),
- roomName = "A room",
- senderName = "Bob",
- avatarUrl = null,
- notificationChannelId = "incoming_call",
- timestamp = 0L,
- ),
- onAnswer = {},
- onCancel = {},
- )
- }
+internal fun IncomingCallScreenPreview() = ElementPreview {
+ IncomingCallScreen(
+ notificationData = CallNotificationData(
+ sessionId = SessionId("@alice:matrix.org"),
+ roomId = RoomId("!1234:matrix.org"),
+ eventId = EventId("\$asdadadsad:matrix.org"),
+ senderId = UserId("@bob:matrix.org"),
+ roomName = "A room",
+ senderName = "Bob",
+ avatarUrl = null,
+ notificationChannelId = "incoming_call",
+ timestamp = 0L,
+ ),
+ onAnswer = {},
+ onCancel = {},
+ )
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
index c02006cb54..1e217da7a8 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
@@ -25,14 +25,25 @@ import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -79,11 +90,16 @@ class DefaultActiveCallManager @Inject constructor(
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat,
+ private val matrixClientProvider: MatrixClientProvider,
) : ActiveCallManager {
private var timedOutCallJob: Job? = null
override val activeCall = MutableStateFlow(null)
+ init {
+ observeRingingCall()
+ }
+
override fun registerIncomingCall(notificationData: CallNotificationData) {
if (activeCall.value != null) {
displayMissedCallNotification(notificationData)
@@ -173,6 +189,35 @@ class DefaultActiveCallManager @Inject constructor(
)
}
}
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun observeRingingCall() {
+ // This will observe ringing calls and ensure they're terminated if the room call is cancelled
+ activeCall
+ .filterNotNull()
+ .filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
+ .flatMapLatest { activeCall ->
+ val callType = activeCall.callType as CallType.RoomCall
+ // Get a flow of updated `hasRoomCall` values for the room
+ matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()
+ ?.getRoom(callType.roomId)
+ ?.roomInfoFlow
+ ?.map { it.hasRoomCall }
+ ?: flowOf()
+ }
+ // We only want to check if the room active call status changes
+ .distinctUntilChanged()
+ // Skip the first one, we're not interested in it (if the check below passes, it had to be active anyway)
+ .drop(1)
+ .onEach { roomHasActiveCall ->
+ if (!roomHasActiveCall) {
+ // The call was cancelled
+ timedOutCallJob?.cancel()
+ incomingCallTimedOut()
+ }
+ }
+ .launchIn(coroutineScope)
+ }
}
/**
diff --git a/features/call/impl/src/main/res/values-de/translations.xml b/features/call/impl/src/main/res/values-de/translations.xml
index d58a616780..6429dbe956 100644
--- a/features/call/impl/src/main/res/values-de/translations.xml
+++ b/features/call/impl/src/main/res/values-de/translations.xml
@@ -3,4 +3,5 @@
"Laufender Anruf"
"Tippen, um zum Anruf zurückzukehren"
"☎️ Anruf läuft"
+ "Eingehender Element Call"
diff --git a/features/call/impl/src/main/res/values-pl/translations.xml b/features/call/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..27133cd91b
--- /dev/null
+++ b/features/call/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Połączenie w trakcie"
+ "Stuknij, aby wrócić do rozmowy"
+ "☎️ Rozmowa w toku"
+
diff --git a/features/call/impl/src/main/res/values-pt-rBR/translations.xml b/features/call/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..c3d90e4bb0
--- /dev/null
+++ b/features/call/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Chamada em andamento"
+ "Toque para retornar à chamada"
+ "☎️ Chamada em andamento"
+
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt
new file mode 100644
index 0000000000..5a4dc98275
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+class FakePipSupportProvider(
+ private val isPipSupported: Boolean
+) : PipSupportProvider {
+ override fun isPipSupported() = isPipSupported
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
new file mode 100644
index 0000000000..895505c278
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+import android.os.Build.VERSION_CODES
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.call.impl.ui.ElementCallActivity
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+class PictureInPicturePresenterTest {
+ @Test
+ @Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
+ fun `when pip is not supported, the state value supportPip is false`() = runTest {
+ val presenter = createPictureInPicturePresenter(supportPip = false)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.supportPip).isFalse()
+ }
+ presenter.onDestroy()
+ }
+
+ @Test
+ @Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
+ fun `when pip is supported, the state value supportPip is true`() = runTest {
+ val presenter = createPictureInPicturePresenter(supportPip = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.supportPip).isTrue()
+ }
+ presenter.onDestroy()
+ }
+
+ @Test
+ @Config(sdk = [VERSION_CODES.S])
+ fun `when entering pip is supported, the state value isInPictureInPicture is true`() = runTest {
+ val presenter = createPictureInPicturePresenter(supportPip = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isInPictureInPicture).isFalse()
+ initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+ presenter.onPictureInPictureModeChanged(true)
+ val pipState = awaitItem()
+ assertThat(pipState.isInPictureInPicture).isTrue()
+ // User stops pip
+ presenter.onPictureInPictureModeChanged(false)
+ val finalState = awaitItem()
+ assertThat(finalState.isInPictureInPicture).isFalse()
+ }
+ presenter.onDestroy()
+ }
+
+ @Test
+ @Config(sdk = [VERSION_CODES.S])
+ fun `when onUserLeaveHint is called, the state value isInPictureInPicture becomes true`() = runTest {
+ val presenter = createPictureInPicturePresenter(supportPip = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isInPictureInPicture).isFalse()
+ presenter.onUserLeaveHint()
+ presenter.onPictureInPictureModeChanged(true)
+ val pipState = awaitItem()
+ assertThat(pipState.isInPictureInPicture).isTrue()
+ }
+ presenter.onDestroy()
+ }
+
+ private fun createPictureInPicturePresenter(
+ supportPip: Boolean = true,
+ ): PictureInPicturePresenter {
+ val activity = Robolectric.buildActivity(ElementCallActivity::class.java)
+ return PictureInPicturePresenter(
+ pipSupportProvider = FakePipSupportProvider(supportPip),
+ ).apply {
+ onCreate(activity.get())
+ }
+ }
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
new file mode 100644
index 0000000000..6d15e5001c
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.ui
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.call.impl.pip.PictureInPictureEvents
+import io.element.android.features.call.impl.pip.PictureInPictureState
+import io.element.android.features.call.impl.pip.aPictureInPictureState
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CallScreenViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back when pip is not supported hangs up`() {
+ val eventsRecorder = EventsRecorder()
+ val pipEventsRecorder = EventsRecorder(expectEvents = false)
+ rule.setCallScreenView(
+ aCallScreenState(
+ eventSink = eventsRecorder
+ ),
+ aPictureInPictureState(
+ supportPip = false,
+ eventSink = pipEventsRecorder,
+ ),
+ )
+ rule.pressBack()
+ eventsRecorder.assertSize(2)
+ eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
+ eventsRecorder.assertTrue(1) { it == CallScreenEvents.Hangup }
+ }
+
+ @Test
+ fun `clicking on back when pip is supported enables PiP`() {
+ val eventsRecorder = EventsRecorder()
+ val pipEventsRecorder = EventsRecorder()
+ rule.setCallScreenView(
+ aCallScreenState(
+ eventSink = eventsRecorder
+ ),
+ aPictureInPictureState(
+ supportPip = true,
+ eventSink = pipEventsRecorder,
+ ),
+ )
+ rule.pressBack()
+ eventsRecorder.assertSize(1)
+ eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
+ pipEventsRecorder.assertSingle(PictureInPictureEvents.EnterPictureInPicture)
+ }
+}
+
+private fun AndroidComposeTestRule.setCallScreenView(
+ state: CallScreenState,
+ pipState: PictureInPictureState,
+ requestPermissions: (Array, RequestPermissionCallback) -> Unit = { _, _ -> },
+) {
+ setContent {
+ CallScreenView(
+ state = state,
+ pipState = pipState,
+ requestPermissions = requestPermissions,
+ )
+ }
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
index d62f55c23d..b6f9e84a60 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
@@ -32,7 +32,10 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
@@ -42,7 +45,11 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
@@ -59,26 +66,28 @@ class DefaultActiveCallManagerTest {
@Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
val notificationManagerCompat = mockk(relaxed = true)
- val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
-
- assertThat(manager.activeCall.value).isNull()
-
- val callNotificationData = aCallNotificationData()
- manager.registerIncomingCall(callNotificationData)
-
- assertThat(manager.activeCall.value).isEqualTo(
- ActiveCall(
- callType = CallType.RoomCall(
- sessionId = callNotificationData.sessionId,
- roomId = callNotificationData.roomId,
- ),
- callState = CallState.Ringing(callNotificationData)
+ inCancellableScope {
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+
+ assertThat(manager.activeCall.value).isNull()
+
+ val callNotificationData = aCallNotificationData()
+ manager.registerIncomingCall(callNotificationData)
+
+ assertThat(manager.activeCall.value).isEqualTo(
+ ActiveCall(
+ callType = CallType.RoomCall(
+ sessionId = callNotificationData.sessionId,
+ roomId = callNotificationData.roomId,
+ ),
+ callState = CallState.Ringing(callNotificationData)
+ )
)
- )
- runCurrent()
+ runCurrent()
- verify { notificationManagerCompat.notify(notificationId, any()) }
+ verify { notificationManagerCompat.notify(notificationId, any()) }
+ }
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -86,38 +95,42 @@ class DefaultActiveCallManagerTest {
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> }
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
- val manager = createActiveCallManager(
- onMissedCallNotificationHandler = onMissedCallNotificationHandler,
- )
+ inCancellableScope {
+ val manager = createActiveCallManager(
+ onMissedCallNotificationHandler = onMissedCallNotificationHandler,
+ )
- // Register existing call
- val callNotificationData = aCallNotificationData()
- manager.registerIncomingCall(callNotificationData)
- val activeCall = manager.activeCall.value
+ // Register existing call
+ val callNotificationData = aCallNotificationData()
+ manager.registerIncomingCall(callNotificationData)
+ val activeCall = manager.activeCall.value
- // Now add a new call
- manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
+ // Now add a new call
+ manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
- assertThat(manager.activeCall.value).isEqualTo(activeCall)
- assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
+ assertThat(manager.activeCall.value).isEqualTo(activeCall)
+ assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
- advanceTimeBy(1)
+ advanceTimeBy(1)
- addMissedCallNotificationLambda.assertions()
- .isCalledOnce()
- .with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
+ addMissedCallNotificationLambda.assertions()
+ .isCalledOnce()
+ .with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
+ }
}
@Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> }
- val manager = createActiveCallManager(
- onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
- )
+ inCancellableScope {
+ val manager = createActiveCallManager(
+ onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
+ )
- manager.incomingCallTimedOut()
+ manager.incomingCallTimedOut()
- addMissedCallNotificationLambda.assertions().isNeverCalled()
+ addMissedCallNotificationLambda.assertions().isNeverCalled()
+ }
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -125,82 +138,167 @@ class DefaultActiveCallManagerTest {
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
val notificationManagerCompat = mockk(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> }
- val manager = createActiveCallManager(
- onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
- notificationManagerCompat = notificationManagerCompat,
- )
+ inCancellableScope {
+ val manager = createActiveCallManager(
+ onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
+ notificationManagerCompat = notificationManagerCompat,
+ )
- manager.registerIncomingCall(aCallNotificationData())
- assertThat(manager.activeCall.value).isNotNull()
+ manager.registerIncomingCall(aCallNotificationData())
+ assertThat(manager.activeCall.value).isNotNull()
- manager.incomingCallTimedOut()
- advanceTimeBy(1)
+ manager.incomingCallTimedOut()
+ advanceTimeBy(1)
- assertThat(manager.activeCall.value).isNull()
- addMissedCallNotificationLambda.assertions().isCalledOnce()
- verify { notificationManagerCompat.cancel(notificationId) }
+ assertThat(manager.activeCall.value).isNull()
+ addMissedCallNotificationLambda.assertions().isCalledOnce()
+ verify { notificationManagerCompat.cancel(notificationId) }
+ }
}
@Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
val notificationManagerCompat = mockk(relaxed = true)
- val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+ // Create a cancellable coroutine scope to cancel the test when needed
+ inCancellableScope {
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
- val notificationData = aCallNotificationData()
- manager.registerIncomingCall(notificationData)
- assertThat(manager.activeCall.value).isNotNull()
+ val notificationData = aCallNotificationData()
+ manager.registerIncomingCall(notificationData)
+ assertThat(manager.activeCall.value).isNotNull()
- manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
- assertThat(manager.activeCall.value).isNull()
+ manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
+ assertThat(manager.activeCall.value).isNull()
- verify { notificationManagerCompat.cancel(notificationId) }
+ verify { notificationManagerCompat.cancel(notificationId) }
+ }
}
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
val notificationManagerCompat = mockk(relaxed = true)
- val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+ // Create a cancellable coroutine scope to cancel the test when needed
+ inCancellableScope {
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
- manager.registerIncomingCall(aCallNotificationData())
- assertThat(manager.activeCall.value).isNotNull()
+ manager.registerIncomingCall(aCallNotificationData())
+ assertThat(manager.activeCall.value).isNotNull()
- manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
- assertThat(manager.activeCall.value).isNotNull()
+ manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
+ assertThat(manager.activeCall.value).isNotNull()
- verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
+ verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
+ }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
val notificationManagerCompat = mockk(relaxed = true)
- val manager = createActiveCallManager(
- notificationManagerCompat = notificationManagerCompat,
- )
- assertThat(manager.activeCall.value).isNull()
-
- manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
- assertThat(manager.activeCall.value).isEqualTo(
- ActiveCall(
- callType = CallType.RoomCall(
- sessionId = A_SESSION_ID,
- roomId = A_ROOM_ID,
- ),
- callState = CallState.InCall,
+ inCancellableScope {
+ val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
+ assertThat(manager.activeCall.value).isNull()
+
+ manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
+ assertThat(manager.activeCall.value).isEqualTo(
+ ActiveCall(
+ callType = CallType.RoomCall(
+ sessionId = A_SESSION_ID,
+ roomId = A_ROOM_ID,
+ ),
+ callState = CallState.InCall,
+ )
)
- )
- runCurrent()
+ runCurrent()
+
+ verify { notificationManagerCompat.cancel(notificationId) }
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `observeRingingCalls - will cancel the active ringing call if the call is cancelled`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenRoomInfo(aRoomInfo())
+ }
+ val client = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ // Create a cancellable coroutine scope to cancel the test when needed
+ inCancellableScope {
+ val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
+ val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
+
+ manager.registerIncomingCall(aCallNotificationData())
+
+ // Call is active (the other user join the call)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
+ advanceTimeBy(1)
+ // Call is cancelled (the other user left the call)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
+ advanceTimeBy(1)
+
+ assertThat(manager.activeCall.value).isNull()
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `observeRingingCalls - will do nothing if either the session or the room are not found`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenRoomInfo(aRoomInfo())
+ }
+ val client = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ // Create a cancellable coroutine scope to cancel the test when needed
+ inCancellableScope {
+ val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
+ val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
+
+ // No matrix client
+
+ manager.registerIncomingCall(aCallNotificationData())
+
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
+ advanceTimeBy(1)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
+ advanceTimeBy(1)
+
+ // The call should still be active
+ assertThat(manager.activeCall.value).isNotNull()
+
+ // No room
+ client.givenGetRoomResult(A_ROOM_ID, null)
+ matrixClientProvider.getClient = { Result.success(client) }
+
+ manager.registerIncomingCall(aCallNotificationData())
+
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
+ advanceTimeBy(1)
+ room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
+ advanceTimeBy(1)
+
+ // The call should still be active
+ assertThat(manager.activeCall.value).isNotNull()
+ }
+ }
- verify { notificationManagerCompat.cancel(notificationId) }
+ private fun TestScope.inCancellableScope(block: suspend CoroutineScope.() -> Unit) {
+ launch(SupervisorJob()) {
+ block()
+ cancel()
+ }
}
- private fun TestScope.createActiveCallManager(
+ private fun CoroutineScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
+ coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager(
- coroutineScope = this,
+ coroutineScope = coroutineScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
@@ -209,5 +307,6 @@ class DefaultActiveCallManagerTest {
notificationBitmapLoader = FakeNotificationBitmapLoader(),
),
notificationManagerCompat = notificationManagerCompat,
+ matrixClientProvider = matrixClientProvider,
)
}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
index 7eb426c35e..a0c2f9a80e 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
@@ -54,9 +54,9 @@ class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - fails if it can't generate the URL for the widget`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenGenerateWidgetWebViewUrlResult(Result.failure(Exception("Can't generate URL for widget")))
- }
+ val room = FakeMatrixRoom(
+ generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.failure(Exception("Can't generate URL for widget")) }
+ )
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
@@ -66,10 +66,10 @@ class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - fails if it can't get the widget driver`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenGenerateWidgetWebViewUrlResult(Result.success("url"))
- givenGetWidgetDriverResult(Result.failure(Exception("Can't get a widget driver")))
- }
+ val room = FakeMatrixRoom(
+ generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
+ getWidgetDriverResult = { Result.failure(Exception("Can't get a widget driver")) }
+ )
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
@@ -79,10 +79,10 @@ class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - returns a widget driver when all steps are successful`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenGenerateWidgetWebViewUrlResult(Result.success("url"))
- givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver()))
- }
+ val room = FakeMatrixRoom(
+ generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
+ getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
+ )
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
@@ -92,10 +92,10 @@ class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - will use a custom base url if it exists`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenGenerateWidgetWebViewUrlResult(Result.success("url"))
- givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver()))
- }
+ val room = FakeMatrixRoom(
+ generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
+ getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
+ )
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
@@ -120,10 +120,10 @@ class DefaultCallWidgetProviderTest {
val elementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { matrixClient ->
providesLambda(matrixClient)
}
- val room = FakeMatrixRoom().apply {
- givenGenerateWidgetWebViewUrlResult(Result.success("url"))
- givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver()))
- }
+ val room = FakeMatrixRoom(
+ generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
+ getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
+ )
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..eb64b46d8c
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Nowy pokój"
+ "Zaproś znajomych"
+ "Wystąpił błąd podczas tworzenia pokoju"
+ "Wiadomości w tym pokoju są szyfrowane. Szyfrowania nie można później wyłączyć."
+ "Pokój prywatny (tylko zaproszenie)"
+ "Wiadomości nie są szyfrowane i każdy może je odczytać. Możesz aktywować szyfrowanie później."
+ "Pokój publiczny (każdy)"
+ "Nazwa pokoju"
+ "Utwórz pokój"
+ "Temat (opcjonalnie)"
+ "Wystąpił błąd podczas próby rozpoczęcia czatu"
+
diff --git a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..bc61411edc
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Nova sala"
+ "Convidar pessoas"
+ "Ocorreu um erro ao criar a sala"
+ "As mensagens nesta sala serão criptografadas. A criptografia não pode ser desativada posteriormente."
+ "Sala privativa (somente por convite)"
+ "As mensagens não serão criptografadas e qualquer pessoa pode lê-las. Você pode ativar a criptografia posteriormente."
+ "Sala pública (qualquer pessoa)"
+ "Nome da sala"
+ "Criar uma sala"
+ "Tópico (opcional)"
+ "Ocorreu um erro ao tentar iniciar um chat"
+
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index 6720fa0274..78ee3c7e69 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -45,7 +45,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -93,11 +93,9 @@ class FtueFlowNode @AssistedInject constructor(
})
analyticsService.didAskUserConsent()
- .drop(1) // We only care about consent passing from not asked to asked state
- .onEach { didAskUserConsent ->
- if (didAskUserConsent) {
- lifecycleScope.launch { moveToNextStep() }
- }
+ .distinctUntilChanged()
+ .onEach {
+ lifecycleScope.launch { moveToNextStep() }
}
.launchIn(lifecycleScope)
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index 049bba4dbb..24c9c7df82 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -24,6 +24,7 @@ import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionStateProvider
@@ -34,6 +35,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
@@ -47,7 +49,7 @@ import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class)
class DefaultFtueService @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
- coroutineScope: CoroutineScope,
+ @SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
@@ -66,11 +68,12 @@ class DefaultFtueService @Inject constructor(
init {
sessionVerificationService.sessionVerifiedStatus
.onEach { updateState() }
- .launchIn(coroutineScope)
+ .launchIn(sessionCoroutineScope)
analyticsService.didAskUserConsent()
+ .distinctUntilChanged()
.onEach { updateState() }
- .launchIn(coroutineScope)
+ .launchIn(sessionCoroutineScope)
}
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
@@ -119,10 +122,7 @@ class DefaultFtueService @Inject constructor(
emit(SessionVerifiedStatus.NotVerified)
}
.first()
- // For some obscure reason we need to call this *before* we check the `readyVerifiedSessionStatus`, otherwise there's a deadlock
- // It seems like a DataStore bug
- val skipVerification = canSkipVerification()
- return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !skipVerification
+ return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !canSkipVerification()
}
private suspend fun canSkipVerification(): Boolean {
@@ -130,7 +130,6 @@ class DefaultFtueService @Inject constructor(
}
private suspend fun needsAnalyticsOptIn(): Boolean {
- // We need this function to not be suspend, so we need to load the value through runBlocking
return analyticsService.didAskUserConsent().first().not()
}
diff --git a/features/ftue/impl/src/main/res/values-pl/translations.xml b/features/ftue/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..b1c1909ac2
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Możesz zmienić ustawienia później."
+ "Zezwól na powiadomienia i nie przegap żadnej wiadomości"
+ "Połączenia, ankiety, wyszukiwanie i inne zostaną dodane później w tym roku."
+ "Historia wiadomości dla pokoi szyfrowanych nie jest jeszcze dostępna."
+ "Chętnie poznamy Twoją opinię. Daj nam znać, co myślisz na stronie ustawień."
+ "Naprzód!"
+ "Oto, co musisz wiedzieć:"
+ "Witamy w %1$s!"
+
diff --git a/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..1528027bd4
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Você pode alterar suas configurações mais tarde."
+ "Permita notificações e nunca perca uma mensagem"
+ "Chamadas, enquetes, pesquisa e muito mais serão adicionadas ainda este ano."
+ "O histórico de mensagens para salas criptografadas ainda não está disponível."
+ "Adoraríamos ouvir sua opinião. Deixe-nos saber o que você pensa através da página de configurações."
+ "Vamos lá!"
+ "Aqui está o que você precisa saber:"
+ "Bem-vindo ao %1$s!"
+
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
index ac2d0724f1..ae2c4dec58 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
@@ -251,7 +251,7 @@ class DefaultFtueServiceTest {
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService(
- coroutineScope = coroutineScope,
+ sessionCoroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService,
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt
index af2c37e68f..ec262a7506 100644
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteStateProvider.kt
@@ -27,13 +27,13 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider {
@Composable
override fun present(): AcceptDeclineInviteState {
@@ -112,7 +112,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
trigger = JoinedRoom.Trigger.Invite,
)
.onSuccess {
- notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
}
.map { roomId }
}
@@ -122,7 +122,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
suspend {
client.getRoom(roomId)?.use {
it.leave().getOrThrow()
- notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
}
roomId
}.runCatchingUpdatingState(declinedAction)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
index 38615fa553..aaa0ee9f51 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt
@@ -79,13 +79,13 @@ private fun DeclineConfirmationDialog(
onDismissClick: () -> Unit,
modifier: Modifier = Modifier
) {
- val contentResource = if (invite.isDirect) {
+ val contentResource = if (invite.isDm) {
R.string.screen_invites_decline_direct_chat_message
} else {
R.string.screen_invites_decline_chat_message
}
- val titleResource = if (invite.isDirect) {
+ val titleResource = if (invite.isDm) {
R.string.screen_invites_decline_direct_chat_title
} else {
R.string.screen_invites_decline_chat_title
diff --git a/features/invite/impl/src/main/res/values-pl/translations.xml b/features/invite/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..e33b1ae106
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Czy na pewno chcesz odrzucić zaproszenie do dołączenia do %1$s?"
+ "Odrzuć zaproszenie"
+ "Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"
+ "Odrzuć czat"
+ "Brak zaproszeń"
+ "%1$s (%2$s) zaprosił Cię"
+
diff --git a/features/invite/impl/src/main/res/values-pt-rBR/translations.xml b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..9cd284690c
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Tem certeza de que deseja recusar o convite para ingressar em %1$s?"
+ "Recusar convite"
+ "Tem certeza de que deseja recusar esse chat privado com %1$s?"
+ "Recusar chat"
+ "Sem convites"
+ "%1$s(%2$s) convidou você"
+
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
index 672c5b58ee..39a0321b01 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
@@ -30,8 +30,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
-import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
-import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
+import io.element.android.libraries.push.api.notifications.NotificationCleaner
+import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -92,9 +92,9 @@ class AcceptDeclineInvitePresenterTest {
val client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
- result = FakeMatrixRoom().apply {
+ result = FakeMatrixRoom(
leaveRoomLambda = declineInviteFailure
- }
+ )
)
}
val presenter = createAcceptDeclineInvitePresenter(client = client)
@@ -133,7 +133,7 @@ class AcceptDeclineInvitePresenterTest {
val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ ->
Result.success(Unit)
}
- val notificationDrawerManager = FakeNotificationDrawerManager(
+ val fakeNotificationCleaner = FakeNotificationCleaner(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda
)
val declineInviteSuccess = lambdaRecorder { ->
@@ -142,14 +142,14 @@ class AcceptDeclineInvitePresenterTest {
val client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
- result = FakeMatrixRoom().apply {
+ result = FakeMatrixRoom(
leaveRoomLambda = declineInviteSuccess
- }
+ )
)
}
val presenter = createAcceptDeclineInvitePresenter(
client = client,
- notificationDrawerManager = notificationDrawerManager,
+ notificationCleaner = fakeNotificationCleaner,
)
presenter.test {
val inviteData = anInviteData()
@@ -219,7 +219,7 @@ class AcceptDeclineInvitePresenterTest {
val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ ->
Result.success(Unit)
}
- val notificationDrawerManager = FakeNotificationDrawerManager(
+ val fakeNotificationCleaner = FakeNotificationCleaner(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda
)
val joinRoomSuccess = lambdaRecorder { _: RoomId, _: List, _: JoinedRoom.Trigger ->
@@ -227,7 +227,7 @@ class AcceptDeclineInvitePresenterTest {
}
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess,
- notificationDrawerManager = notificationDrawerManager,
+ notificationCleaner = fakeNotificationCleaner,
)
presenter.test {
val inviteData = anInviteData()
@@ -260,12 +260,12 @@ class AcceptDeclineInvitePresenterTest {
private fun anInviteData(
roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME,
- isDirect: Boolean = false
+ isDm: Boolean = false
): InviteData {
return InviteData(
roomId = roomId,
roomName = name,
- isDirect = isDirect
+ isDm = isDm
)
}
@@ -274,12 +274,12 @@ class AcceptDeclineInvitePresenterTest {
joinRoomLambda: (RoomId, List, JoinedRoom.Trigger) -> Result = { _, _, _ ->
Result.success(Unit)
},
- notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager(),
+ notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
): AcceptDeclineInvitePresenter {
return AcceptDeclineInvitePresenter(
client = client,
joinRoom = FakeJoinRoom(joinRoomLambda),
- notificationDrawerManager = notificationDrawerManager,
+ notificationCleaner = notificationCleaner,
)
}
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index 5f1efe827e..bba2a82a34 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.ui.model.toInviteSender
@@ -173,7 +174,7 @@ private fun RoomPreview.toContentState(): ContentState {
topic = topic,
alias = canonicalAlias,
numberOfMembers = numberOfJoinedMembers,
- isDirect = false,
+ isDm = false,
roomType = roomType,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when {
@@ -194,7 +195,7 @@ internal fun RoomDescription.toContentState(): ContentState {
topic = topic,
alias = alias,
numberOfMembers = numberOfMembers,
- isDirect = false,
+ isDm = false,
roomType = RoomType.Room,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (joinRule) {
@@ -213,7 +214,7 @@ internal fun MatrixRoomInfo.toContentState(): ContentState {
topic = topic,
alias = canonicalAlias,
numberOfMembers = activeMembersCount,
- isDirect = isDirect,
+ isDm = isDm,
roomType = if (isSpace) RoomType.Space else RoomType.Room,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when {
@@ -233,7 +234,7 @@ internal fun ContentState.toInviteData(): InviteData? {
roomId = roomId,
// Note: name should not be null at this point, but use Id just in case...
roomName = name ?: roomId.value,
- isDirect = isDirect
+ isDm = isDm
)
else -> null
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
index 905d5dd2d1..ab66d0d80c 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
@@ -53,7 +53,7 @@ sealed interface ContentState {
val topic: String?,
val alias: RoomAlias?,
val numberOfMembers: Long?,
- val isDirect: Boolean,
+ val isDm: Boolean,
val roomType: RoomType,
val roomAvatarUrl: String?,
val joinAuthorisationStatus: JoinAuthorisationStatus,
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
index f897026600..8da0573b51 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.ui.model.InviteSender
open class JoinRoomStateProvider : PreviewParameterProvider {
@@ -84,6 +85,12 @@ open class JoinRoomStateProvider : PreviewParameterProvider {
roomType = RoomType.Space,
)
),
+ aJoinRoomState(
+ contentState = aLoadedContentState(
+ name = "A DM",
+ isDm = true,
+ )
+ ),
)
}
@@ -106,7 +113,7 @@ fun aLoadedContentState(
alias: RoomAlias? = RoomAlias("#exa:matrix.org"),
topic: String? = "Element X is a secure, private and decentralized messenger.",
numberOfMembers: Long? = null,
- isDirect: Boolean = false,
+ isDm: Boolean = false,
roomType: RoomType = RoomType.Room,
roomAvatarUrl: String? = null,
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown
@@ -116,7 +123,7 @@ fun aLoadedContentState(
alias = alias,
topic = topic,
numberOfMembers = numberOfMembers,
- isDirect = isDirect,
+ isDm = isDm,
roomType = roomType,
roomAvatarUrl = roomAvatarUrl,
joinAuthorisationStatus = joinAuthorisationStatus
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
index 2a4989e613..05e906b4e4 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
@@ -155,13 +155,13 @@ private fun JoinRoomFooter(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineInvite,
modifier = Modifier.weight(1f),
- size = ButtonSize.Large,
+ size = ButtonSize.LargeLowPadding,
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptInvite,
modifier = Modifier.weight(1f),
- size = ButtonSize.Large,
+ size = ButtonSize.LargeLowPadding,
)
}
}
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
index 2a054b820f..7a6e08244c 100644
--- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
@@ -93,7 +93,7 @@ class JoinRoomPresenterTest {
assertThat(contentState.topic).isEqualTo(roomInfo.topic)
assertThat(contentState.alias).isEqualTo(roomInfo.canonicalAlias)
assertThat(contentState.numberOfMembers).isEqualTo(roomInfo.activeMembersCount)
- assertThat(contentState.isDirect).isEqualTo(roomInfo.isDirect)
+ assertThat(contentState.isDm).isEqualTo(roomInfo.isDirect)
assertThat(contentState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl)
}
}
@@ -283,7 +283,7 @@ class JoinRoomPresenterTest {
assertThat(contentState.topic).isEqualTo(roomDescription.topic)
assertThat(contentState.alias).isEqualTo(roomDescription.alias)
assertThat(contentState.numberOfMembers).isEqualTo(roomDescription.numberOfMembers)
- assertThat(contentState.isDirect).isFalse()
+ assertThat(contentState.isDm).isFalse()
assertThat(contentState.roomAvatarUrl).isEqualTo(roomDescription.avatarUrl)
}
}
@@ -398,7 +398,7 @@ class JoinRoomPresenterTest {
topic = "Room topic",
alias = RoomAlias("#alias:matrix.org"),
numberOfMembers = 2,
- isDirect = false,
+ isDm = false,
roomType = RoomType.Room,
roomAvatarUrl = "avatarUrl",
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
diff --git a/features/leaveroom/api/src/main/res/values-pl/translations.xml b/features/leaveroom/api/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..0fd8769389
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-pl/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Czy na pewno chcesz opuścić tę konwersację? Konwersacja nie jest publiczna i nie będziesz mógł dołączyć ponownie bez zaproszenia."
+ "Jesteś pewien, że chcesz opuścić ten pokój? Jesteś tu jedyną osobą. Jeśli wyjdziesz, nikt nie będzie mógł dołączyć, w tym Ty."
+ "Czy na pewno chcesz opuścić ten pokój? Ten pokój nie jest publiczny i nie będziesz mógł do niego wrócić bez zaproszenia."
+ "Jesteś pewien, że chcesz wyjść z tego pokoju?"
+
diff --git a/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml b/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..bb4f35d07f
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Tem certeza de que deseja sair desta sala? Você é a única pessoa aqui. Se você sair, ninguém poderá ingressar no futuro, inclusive você."
+ "Tem certeza de que deseja sair desta sala? Esta sala não é pública e você não poderá entrar novamente sem um convite."
+ "Tem certeza de que deseja sair da sala?"
+
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenter.kt
index c949a5f446..4b121073a0 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenter.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenter.kt
@@ -34,6 +34,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.room.isDm
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenterTest.kt
index 9962a3bd32..a063b9ca90 100644
--- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenterTest.kt
+++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenterTest.kt
@@ -119,7 +119,7 @@ class DefaultLeaveRoomPresenterTest {
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
- result = FakeMatrixRoom(activeMemberCount = 2, isDirect = true, isOneToOne = true),
+ result = FakeMatrixRoom(activeMemberCount = 2, isDirect = true),
)
}
)
@@ -140,7 +140,9 @@ class DefaultLeaveRoomPresenterTest {
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
- result = FakeMatrixRoom(),
+ result = FakeMatrixRoom(
+ leaveRoomLambda = { Result.success(Unit) }
+ ),
)
},
roomMembershipObserver = roomMembershipObserver
@@ -162,9 +164,9 @@ class DefaultLeaveRoomPresenterTest {
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
- result = FakeMatrixRoom().apply {
- this.leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
- },
+ result = FakeMatrixRoom(
+ leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
+ ),
)
}
)
@@ -186,7 +188,9 @@ class DefaultLeaveRoomPresenterTest {
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
- result = FakeMatrixRoom(),
+ result = FakeMatrixRoom(
+ leaveRoomLambda = { Result.success(Unit) }
+ ),
)
}
)
@@ -208,9 +212,9 @@ class DefaultLeaveRoomPresenterTest {
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
- result = FakeMatrixRoom().apply {
- this.leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
- },
+ result = FakeMatrixRoom(
+ leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) }
+ ),
)
}
)
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
index 93e15f7868..24fcd3537c 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
@@ -29,13 +29,15 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.messages.test.FakeMessageComposerContext
+import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -46,16 +48,18 @@ class SendLocationPresenterTest {
val warmUpRule = WarmUpRule()
private val fakePermissionsPresenter = FakePermissionsPresenter()
- private val fakeMatrixRoom = FakeMatrixRoom()
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
- private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
+
+ private fun createSendLocationPresenter(
+ matrixRoom: MatrixRoom = FakeMatrixRoom(),
+ ): SendLocationPresenter = SendLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter
},
- room = fakeMatrixRoom,
+ room = matrixRoom,
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
locationActions = fakeLocationActions,
@@ -64,6 +68,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions granted`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
@@ -90,6 +95,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions partially granted`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.SomeGranted,
@@ -116,6 +122,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions denied`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -142,6 +149,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions denied once`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -168,6 +176,7 @@ class SendLocationPresenterTest {
@Test
fun `rationale dialog dismiss`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -199,6 +208,7 @@ class SendLocationPresenterTest {
@Test
fun `rationale dialog continue`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -227,6 +237,7 @@ class SendLocationPresenterTest {
@Test
fun `permission denied dialog dismiss`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -258,6 +269,13 @@ class SendLocationPresenterTest {
@Test
fun `share sender location`() = runTest {
+ val sendLocationResult = lambdaRecorder> { _, _, _, _, _ ->
+ Result.success(Unit)
+ }
+ val matrixRoom = FakeMatrixRoom(
+ sendLocationResult = sendLocationResult,
+ )
+ val sendLocationPresenter = createSendLocationPresenter(matrixRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
@@ -289,16 +307,14 @@ class SendLocationPresenterTest {
delay(1) // Wait for the coroutine to finish
- assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
- assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
- SendLocationInvocation(
- body = "Location was shared at geo:3.0,4.0;u=5.0",
- geoUri = "geo:3.0,4.0;u=5.0",
- description = null,
- zoomLevel = 15,
- assetType = AssetType.SENDER
+ sendLocationResult.assertions().isCalledOnce()
+ .with(
+ value("Location was shared at geo:3.0,4.0;u=5.0"),
+ value("geo:3.0,4.0;u=5.0"),
+ value(null),
+ value(15),
+ value(AssetType.SENDER),
)
- )
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
@@ -314,6 +330,13 @@ class SendLocationPresenterTest {
@Test
fun `share pin location`() = runTest {
+ val sendLocationResult = lambdaRecorder> { _, _, _, _, _ ->
+ Result.success(Unit)
+ }
+ val matrixRoom = FakeMatrixRoom(
+ sendLocationResult = sendLocationResult,
+ )
+ val sendLocationPresenter = createSendLocationPresenter(matrixRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -345,16 +368,14 @@ class SendLocationPresenterTest {
delay(1) // Wait for the coroutine to finish
- assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
- assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
- SendLocationInvocation(
- body = "Location was shared at geo:0.0,1.0",
- geoUri = "geo:0.0,1.0",
- description = null,
- zoomLevel = 15,
- assetType = AssetType.PIN
+ sendLocationResult.assertions().isCalledOnce()
+ .with(
+ value("Location was shared at geo:0.0,1.0"),
+ value("geo:0.0,1.0"),
+ value(null),
+ value(15),
+ value(AssetType.PIN),
)
- )
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
@@ -370,6 +391,13 @@ class SendLocationPresenterTest {
@Test
fun `composer context passes through analytics`() = runTest {
+ val sendLocationResult = lambdaRecorder> { _, _, _, _, _ ->
+ Result.success(Unit)
+ }
+ val matrixRoom = FakeMatrixRoom(
+ sendLocationResult = sendLocationResult,
+ )
+ val sendLocationPresenter = createSendLocationPresenter(matrixRoom)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -418,6 +446,7 @@ class SendLocationPresenterTest {
@Test
fun `open settings activity`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -452,6 +481,7 @@ class SendLocationPresenterTest {
@Test
fun `application name is in state`() = runTest {
+ val sendLocationPresenter = createSendLocationPresenter()
moleculeFlow(RecompositionMode.Immediate) {
sendLocationPresenter.present()
}.test {
diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts
index 1cbe9692cf..21f3e05421 100644
--- a/features/lockscreen/impl/build.gradle.kts
+++ b/features/lockscreen/impl/build.gradle.kts
@@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
index b0b7ab75b3..55b53f054c 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
@@ -17,7 +17,6 @@
package io.element.android.features.lockscreen.impl
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.appconfig.LockScreenConfig
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
@@ -55,7 +54,7 @@ class DefaultLockScreenService @Inject constructor(
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService,
- private val biometricUnlockManager: BiometricUnlockManager,
+ biometricUnlockManager: BiometricUnlockManager,
) : LockScreenService {
private val _lockState = MutableStateFlow(LockScreenLockState.Unlocked)
override val lockState: StateFlow = _lockState
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt
new file mode 100644
index 0000000000..b6f901de59
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.lockscreen.impl
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import io.element.android.libraries.di.AppScope
+import kotlin.time.Duration
+import io.element.android.appconfig.LockScreenConfig as AppConfigLockScreenConfig
+
+data class LockScreenConfig(
+ val isPinMandatory: Boolean,
+ val forbiddenPinCodes: Set,
+ val pinSize: Int,
+ val maxPinCodeAttemptsBeforeLogout: Int,
+ val gracePeriod: Duration,
+ val isStrongBiometricsEnabled: Boolean,
+ val isWeakBiometricsEnabled: Boolean,
+)
+
+@ContributesTo(AppScope::class)
+@Module
+object LockScreenConfigModule {
+ @Provides
+ fun providesLockScreenConfig(): LockScreenConfig = LockScreenConfig(
+ isPinMandatory = AppConfigLockScreenConfig.IS_PIN_MANDATORY,
+ forbiddenPinCodes = AppConfigLockScreenConfig.FORBIDDEN_PIN_CODES,
+ pinSize = AppConfigLockScreenConfig.PIN_SIZE,
+ maxPinCodeAttemptsBeforeLogout = AppConfigLockScreenConfig.MAX_PIN_CODE_ATTEMPTS_BEFORE_LOGOUT,
+ gracePeriod = AppConfigLockScreenConfig.GRACE_PERIOD,
+ isStrongBiometricsEnabled = AppConfigLockScreenConfig.IS_STRONG_BIOMETRICS_ENABLED,
+ isWeakBiometricsEnabled = AppConfigLockScreenConfig.IS_WEAK_BIOMETRICS_ENABLED,
+ )
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt
index 95951866f3..68020ba20a 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt
@@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.R
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
index fbbe0442c1..b0cb97350a 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
@@ -23,7 +23,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
index ff2bdc56db..eec7e6a19b 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
@@ -22,7 +22,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
@@ -76,7 +76,7 @@ class SetupPinPresenter @Inject constructor(
if (confirmPinEntry == choosePinEntry) {
pinCodeManager.createPinCode(confirmPinEntry.toText())
} else {
- setupPinFailure = SetupPinFailure.PinsDontMatch
+ setupPinFailure = SetupPinFailure.PinsDoNotMatch
}
}
}
@@ -93,11 +93,11 @@ class SetupPinPresenter @Inject constructor(
}
SetupPinEvents.ClearFailure -> {
when (setupPinFailure) {
- is SetupPinFailure.PinsDontMatch -> {
+ is SetupPinFailure.PinsDoNotMatch -> {
choosePinEntry = choosePinEntry.clear()
confirmPinEntry = confirmPinEntry.clear()
}
- is SetupPinFailure.PinBlacklisted -> {
+ is SetupPinFailure.ForbiddenPin -> {
choosePinEntry = choosePinEntry.clear()
}
null -> Unit
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt
index d1820ea75d..f8d35ec630 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt
@@ -35,11 +35,11 @@ open class SetupPinStateProvider : PreviewParameterProvider {
choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"),
confirmPinEntry = PinEntry.createEmpty(4).fillWith("1788"),
isConfirmationStep = true,
- creationFailure = SetupPinFailure.PinsDontMatch
+ creationFailure = SetupPinFailure.PinsDoNotMatch
),
aSetupPinState(
choosePinEntry = PinEntry.createEmpty(4).fillWith("1111"),
- creationFailure = SetupPinFailure.PinBlacklisted
+ creationFailure = SetupPinFailure.ForbiddenPin
),
)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
index 2a0e890470..ad0b375812 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
@@ -135,16 +135,16 @@ private fun SetupPinContent(
@Composable
private fun SetupPinFailure.content(): String {
return when (this) {
- SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
- SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
+ SetupPinFailure.ForbiddenPin -> stringResource(id = R.string.screen_app_lock_setup_pin_forbidden_dialog_content)
+ SetupPinFailure.PinsDoNotMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
}
}
@Composable
private fun SetupPinFailure.title(): String {
return when (this) {
- SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
- SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
+ SetupPinFailure.ForbiddenPin -> stringResource(id = R.string.screen_app_lock_setup_pin_forbidden_dialog_title)
+ SetupPinFailure.PinsDoNotMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt
index 22e6275079..b7f96ecec8 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt
@@ -16,7 +16,7 @@
package io.element.android.features.lockscreen.impl.setup.pin.validation
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import javax.inject.Inject
@@ -28,9 +28,9 @@ class PinValidator @Inject constructor(private val lockScreenConfig: LockScreenC
fun isPinValid(pinEntry: PinEntry): Result {
val pinAsText = pinEntry.toText()
- val isBlacklisted = lockScreenConfig.pinBlacklist.any { it == pinAsText }
- return if (isBlacklisted) {
- Result.Invalid(SetupPinFailure.PinBlacklisted)
+ val isForbidden = lockScreenConfig.forbiddenPinCodes.any { it == pinAsText }
+ return if (isForbidden) {
+ Result.Invalid(SetupPinFailure.ForbiddenPin)
} else {
Result.Valid
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt
index 271dcc2f2c..66f3cd0731 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt
@@ -17,6 +17,6 @@
package io.element.android.features.lockscreen.impl.setup.pin.validation
sealed interface SetupPinFailure {
- data object PinBlacklisted : SetupPinFailure
- data object PinsDontMatch : SetupPinFailure
+ data object ForbiddenPin : SetupPinFailure
+ data object PinsDoNotMatch : SetupPinFailure
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
index b32ef8fd4c..dd0e4c67f6 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
@@ -25,7 +25,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt
index 9c228b0736..7b7b16790f 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt
@@ -24,13 +24,14 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
-import io.element.android.compound.theme.ElementTheme
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter
import io.element.android.features.lockscreen.impl.unlock.PinUnlockView
import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.designsystem.theme.ElementThemeApp
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -43,13 +44,14 @@ class PinUnlockActivity : AppCompatActivity() {
@Inject lateinit var presenter: PinUnlockPresenter
@Inject lateinit var lockScreenService: LockScreenService
+ @Inject lateinit var appPreferencesStore: AppPreferencesStore
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
bindings().inject(this)
setContent {
- ElementTheme {
+ ElementThemeApp(appPreferencesStore) {
val state = presenter.present()
PinUnlockView(state = state, isInAppUnlock = false)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
index 4033a1b6f7..c54fdb67f3 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
@@ -37,7 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.coerceAtMost
+import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times
@@ -50,6 +50,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
private val spaceBetweenPinKey = 16.dp
+private val minSizePinKey = 16.dp
private val maxSizePinKey = 80.dp
@Composable
@@ -61,8 +62,8 @@ fun PinKeypad(
verticalAlignment: Alignment.Vertical = Alignment.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
) {
- val pinKeyMaxWidth = ((maxWidth - 2 * spaceBetweenPinKey) / 3).coerceAtMost(maxSizePinKey)
- val pinKeyMaxHeight = ((maxHeight - 3 * spaceBetweenPinKey) / 4).coerceAtMost(maxSizePinKey)
+ val pinKeyMaxWidth = ((maxWidth - 2 * spaceBetweenPinKey) / 3).coerceIn(minSizePinKey, maxSizePinKey)
+ val pinKeyMaxHeight = ((maxHeight - 3 * spaceBetweenPinKey) / 4).coerceIn(minSizePinKey, maxSizePinKey)
val pinKeySize = if (pinKeyMaxWidth < pinKeyMaxHeight) pinKeyMaxWidth else pinKeyMaxHeight
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
@@ -129,7 +130,7 @@ private fun PinKeypadRow(
)
}
is PinKeypadModel.Number -> {
- PinKeyBadDigitButton(
+ PinKeypadDigitButton(
size = pinKeySize,
modifier = commonModifier,
digit = model.number.toString(),
@@ -158,7 +159,7 @@ private fun PinKeypadButton(
}
@Composable
-private fun PinKeyBadDigitButton(
+private fun PinKeypadDigitButton(
digit: String,
size: Dp,
onClick: (String) -> Unit,
diff --git a/features/lockscreen/impl/src/main/res/values-be/translations.xml b/features/lockscreen/impl/src/main/res/values-be/translations.xml
index e38af70014..7667034623 100644
--- a/features/lockscreen/impl/src/main/res/values-be/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-be/translations.xml
@@ -14,11 +14,11 @@
"Эканомце час і выкарыстоўвайце %1$s для разблакіроўкі праграмы"
"Выберыце PIN-код"
"Пацвярджэнне PIN-кода"
- "Вы не можаце выбраць гэты PIN-код з меркаванняў бяспекі"
- "Выберыце іншы PIN-код"
"Заблакіруйце %1$s, каб павялічыць бяспеку вашых чатаў.
Абярыце што-небудзь незабыўнае. Калі вы забудзецеся гэты PIN-код, вы выйдзеце з праграмы."
+ "Вы не можаце выбраць гэты PIN-код з меркаванняў бяспекі"
+ "Выберыце іншы PIN-код"
"Увядзіце адзін і той жа PIN двойчы"
"PIN-коды не супадаюць"
"Каб працягнуць, вам неабходна паўторна ўвайсці ў сістэму і стварыць новы PIN-код"
diff --git a/features/lockscreen/impl/src/main/res/values-bg/translations.xml b/features/lockscreen/impl/src/main/res/values-bg/translations.xml
index a1b8ecda84..af8e8a9853 100644
--- a/features/lockscreen/impl/src/main/res/values-bg/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-bg/translations.xml
@@ -9,7 +9,7 @@
"Предпочитам да използвам PIN"
"Избор на PIN"
"Потвърждаване на PIN"
- "Избор на различен PIN"
+ "Избор на различен PIN"
"Моля, въведете един и същ PIN два пъти"
"PINs не съвпадат"
diff --git a/features/lockscreen/impl/src/main/res/values-cs/translations.xml b/features/lockscreen/impl/src/main/res/values-cs/translations.xml
index 264b9f08e5..b9984b1dc3 100644
--- a/features/lockscreen/impl/src/main/res/values-cs/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-cs/translations.xml
@@ -14,11 +14,11 @@
"Ušetřete si čas a použijte pokaždé %1$s pro odemknutí aplikace"
"Zvolte PIN"
"Potvrďte PIN"
- "Z bezpečnostních důvodů si toto nemůžete zvolit jako svůj PIN kód"
- "Zvolte jiný PIN"
"Zamkněte %1$s pro zvýšení bezpečnosti vašich konverzací.
Vyberte si něco zapamatovatelného. Pokud tento kód PIN zapomenete, budete z aplikace odhlášeni."
+ "Z bezpečnostních důvodů si toto nemůžete zvolit jako svůj PIN kód"
+ "Zvolte jiný PIN"
"Zadejte stejný PIN dvakrát"
"PIN kódy se neshodují."
"Abyste mohli pokračovat, budete se muset znovu přihlásit a vytvořit nový PIN"
diff --git a/features/lockscreen/impl/src/main/res/values-de/translations.xml b/features/lockscreen/impl/src/main/res/values-de/translations.xml
index 53df8aeca0..bf89b3f5e4 100644
--- a/features/lockscreen/impl/src/main/res/values-de/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-de/translations.xml
@@ -14,11 +14,11 @@
"Spare dir etwas Zeit und benutze %1$s, um die App zu entsperren"
"PIN wählen"
"PIN bestätigen"
- "Aus Sicherheitsgründen kann dieser PIN-Code nicht verwendet werden."
- "Bitte eine andere PIN verwenden."
"Sperre %1$s mit einem PIN Code, um den Zugriff auf deine Chats zu beschränken.
Wähle etwas Einprägsames. Bei falscher Eingabe wirst du aus der App ausgeloggt."
+ "Aus Sicherheitsgründen kann dieser PIN-Code nicht verwendet werden."
+ "Bitte eine andere PIN verwenden."
"Bitte gib die gleiche PIN wie zuvor ein."
"Die PINs stimmen nicht überein"
"Um fortzufahren, musst du dich erneut anmelden und eine neue PIN erstellen"
diff --git a/features/lockscreen/impl/src/main/res/values-el/translations.xml b/features/lockscreen/impl/src/main/res/values-el/translations.xml
index a7a8e954c1..ebc22971c2 100644
--- a/features/lockscreen/impl/src/main/res/values-el/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-el/translations.xml
@@ -14,11 +14,11 @@
"Εξοικονόμησε χρόνο και χρησιμοποίησε %1$s για να ξεκλειδώσεις την εφαρμογή κάθε φορά"
"Επέλεξε PIN"
"Επιβεβαίωση PIN"
- "Δεν μπορείς να το επιλέξεις ως κωδικό PIN για λόγους ασφαλείας"
- "Επέλεξε διαφορετικό PIN"
"Κλείδωμα του %1$s για να προσθέσεις επιπλέον ασφάλεια στις συνομιλίες σου.
Επέλεξε κάτι αξιομνημόνευτο. Εάν ξεχάσεις αυτό το PIN, θα αποσυνδεθείς από την εφαρμογή."
+ "Δεν μπορείς να το επιλέξεις ως κωδικό PIN για λόγους ασφαλείας"
+ "Επέλεξε διαφορετικό PIN"
"Παρακαλώ εισήγαγε το ίδιο PIN δύο φορές"
"Τα PIN δεν ταιριάζουν"
"Θα χρειαστεί να συνδεθείς ξανά και να δημιουργήσεις ένα νέο PIN για να προχωρήσεις"
diff --git a/features/lockscreen/impl/src/main/res/values-es/translations.xml b/features/lockscreen/impl/src/main/res/values-es/translations.xml
index c034d14fb0..1ab64c8f3b 100644
--- a/features/lockscreen/impl/src/main/res/values-es/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-es/translations.xml
@@ -14,11 +14,11 @@
"Ahorra algo de tiempo y usa %1$s para desbloquear la aplicación cada vez"
"Elegir PIN"
"Confirmar PIN"
- "No puedes usar este código PIN por motivos de seguridad"
- "Elige un PIN diferente"
"Añade un bloqueo a %1$s para añadir seguridad adicional a tus chats.
Elige algo que puedas recordar. Si olvidas este PIN, se cerrará la sesión de la aplicación."
+ "No puedes usar este código PIN por motivos de seguridad"
+ "Elige un PIN diferente"
"Por favor ingresa el mismo PIN dos veces"
"Los PINs no coinciden"
"Tendrás que volver a iniciar sesión y crear un nuevo PIN para continuar"
diff --git a/features/lockscreen/impl/src/main/res/values-et/translations.xml b/features/lockscreen/impl/src/main/res/values-et/translations.xml
index fd2632b884..25c2300510 100644
--- a/features/lockscreen/impl/src/main/res/values-et/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-et/translations.xml
@@ -14,11 +14,11 @@
"Säästa aega ja kasuta alati %1$s rakenduse lukustuse eemaldamiseks"
"Vali PIN-kood"
"Korda PIN-koodi"
- "Turvakaalutlustel sa ei saa sellist PIN-koodi kasutada"
- "Kasuta mõnda teist PIN-koodi"
"Lisamaks oma %1$s vestlustele turvalisust ja privaatsust, lukusta oma nutiseade.
Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvakaalutlustel logitakse sind rakendusest välja."
+ "Turvakaalutlustel sa ei saa sellist PIN-koodi kasutada"
+ "Kasuta mõnda teist PIN-koodi"
"Palun sisesta sama PIN-kood kaks korda"
"PIN-koodid ei klapi omavahel"
"Jätkamaks pead uuesti sisse logima ja looma uue PIN-koodi"
diff --git a/features/lockscreen/impl/src/main/res/values-fr/translations.xml b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
index e086a88e61..a8b3757022 100644
--- a/features/lockscreen/impl/src/main/res/values-fr/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
@@ -14,11 +14,11 @@
"Gagnez du temps en utilisant %1$s pour déverrouiller l’application à chaque fois."
"Choisissez un code PIN"
"Confirmer le code PIN"
- "Vous ne pouvez pas choisir ce code PIN pour des raisons de sécurité"
- "Choisissez un code PIN différent"
"Verrouillez %1$s pour ajouter une sécurité supplémentaire à vos discussions.
Choisissez un code facile à retenir. Si vous oubliez le code PIN, vous serez déconnecté."
+ "Vous ne pouvez pas choisir ce code PIN pour des raisons de sécurité"
+ "Choisissez un code PIN différent"
"Veuillez saisir le même code PIN deux fois"
"Les codes PIN ne correspondent pas"
"Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN."
diff --git a/features/lockscreen/impl/src/main/res/values-hu/translations.xml b/features/lockscreen/impl/src/main/res/values-hu/translations.xml
index fba0444c15..2df43b3fae 100644
--- a/features/lockscreen/impl/src/main/res/values-hu/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-hu/translations.xml
@@ -14,11 +14,11 @@
"Spóroljon meg némi időt, és használja a %1$st az alkalmazás feloldásához"
"PIN-kód kiválasztása"
"PIN-kód megerősítése"
- "Ezt biztonsági okokból nem választhatja PIN-kódként"
- "Válasszon egy másik PIN-kódot"
"Az %1$s zárolása a csevegései nagyobb biztonsága érdekében.
Válasszon valami megjegyezhetőt. Ha elfelejti a PIN-kódot, akkor ki lesz jelentkeztetve az alkalmazásból."
+ "Ezt biztonsági okokból nem választhatja PIN-kódként"
+ "Válasszon egy másik PIN-kódot"
"Adja meg a PIN-kódját kétszer"
"A PIN-kódok nem egyeznek"
"A folytatáshoz újra be kell jelentkeznie, és létre kell hoznia egy új PIN-kódot"
diff --git a/features/lockscreen/impl/src/main/res/values-in/translations.xml b/features/lockscreen/impl/src/main/res/values-in/translations.xml
index 9a591be4c6..d6c7c3bc94 100644
--- a/features/lockscreen/impl/src/main/res/values-in/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-in/translations.xml
@@ -14,11 +14,11 @@
"Hemat waktu Anda dan gunakan %1$s untuk membuka kunci aplikasi setiap kalinya"
"Pilih PIN"
"Konfirmasi PIN"
- "Anda tidak dapat memilih PIN ini demi keamanan"
- "Pilih PIN yang lain"
"Kunci %1$s untuk menambahkan keamanan tambahan pada percakapan Anda.
Pilih sesuatu yang mudah untuk diingat. Jika Anda lupa PIN ini, Anda akan dikeluarkan dari aplikasi."
+ "Anda tidak dapat memilih PIN ini demi keamanan"
+ "Pilih PIN yang lain"
"Silakan masukkan PIN yang sama dua kali"
"PIN tidak cocok"
"Anda harus masuk ulang dan membuat PIN baru untuk melanjutkan"
diff --git a/features/lockscreen/impl/src/main/res/values-it/translations.xml b/features/lockscreen/impl/src/main/res/values-it/translations.xml
index 3c9cfbe45d..cdb48f8f63 100644
--- a/features/lockscreen/impl/src/main/res/values-it/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-it/translations.xml
@@ -14,11 +14,11 @@
"Risparmia un po\' di tempo e usa %1$s per sbloccare l\'app ogni volta"
"Scegli il PIN"
"Conferma il PIN"
- "Non puoi scegliere questo codice PIN per motivi di sicurezza"
- "Scegli un PIN diverso"
"Blocca %1$s per aggiungere ulteriore sicurezza alle tue conversazioni.
Scegli qualcosa facile da ricordare. Se dimentichi questo PIN, verrai disconnesso dall\'app."
+ "Non puoi scegliere questo codice PIN per motivi di sicurezza"
+ "Scegli un PIN diverso"
"Inserisci lo stesso PIN due volte"
"I PIN non corrispondono"
"Dovrai effettuare nuovamente l\'accesso e creare un nuovo PIN per procedere"
diff --git a/features/lockscreen/impl/src/main/res/values-ka/translations.xml b/features/lockscreen/impl/src/main/res/values-ka/translations.xml
index 26fd97b671..022d4ee33c 100644
--- a/features/lockscreen/impl/src/main/res/values-ka/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-ka/translations.xml
@@ -1,12 +1,26 @@
+ "ბიომეტრიული ავტორიზაცია"
+ "ბიომეტრიული განბლოკვა"
+ "განბლოკვა ბიომეტრიით"
"დაგავიწყდათ PIN?"
"PIN კოდის შეცვლა"
"ბიომეტრიული განბლოკვის დაშვება"
"პინ კოდის წაშლა"
"დარწმუნებული ხართ, რომ გსურთ PIN-ის წაშლა?"
"გსურთ PIN-ის წაშლა?"
+ "%1$s დაშვება"
+ "მირჩევნია PIN-ის გამოყენება"
+ "დაზოგეთ დრო და გამოიყენეთ %1$s აპლიკაციის განსაბლოკად."
+ "აირჩიეთ PIN"
"დაადასტურეთ PIN"
+ "თქვენი ჩატების დამატებითი უსაფრთხოებისათვის დაბლოკეთ %1$s.
+
+აირჩიეთ რაიმე ისეთი, რაც დაგამახსოვრდებათ. თუ დაგავიწყდებათ ეს PIN, აპლიკაციიდან გამოხვალთ."
+ "თქვენ არ შეგიძლიათ აირჩიოთ ეს PIN კოდი უსაფრთხოების მიზეზების გამო"
+ "აირჩიეთ სხვა PIN"
+ "გთხოვთ შეიყვანოთ იგივე PIN ორჯერ"
+ "PIN-ები არ ემთხვევა"
"გასაგრძელებლად საჭიროა ხელახლა შესვლა და ახალი PIN-ის შექმნა"
"თქვენ ახლა გადიხართ…"
@@ -17,5 +31,7 @@
- "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ"
- "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ"
+ "გამოიყენეთ ბიომეტრია"
+ "გამოიყენეთ PIN"
"გასვლა…"
diff --git a/features/lockscreen/impl/src/main/res/values-pl/translations.xml b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..1eb904b13e
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,39 @@
+
+
+ "uwierzytelnienie biometryczne"
+ "odblokowanie biometryczne"
+ "Odblokuj za pomocą biometrii"
+ "Nie pamiętasz kodu PIN?"
+ "Zmień kod PIN"
+ "Zezwól na uwierzytelnienie biometryczne"
+ "Usuń PIN"
+ "Czy na pewno chcesz usunąć PIN?"
+ "Usunąć PIN?"
+ "Zezwól na %1$s"
+ "Wolę korzystać z kodu PIN"
+ "Zaoszczędź sobie trochę czasu i korzystaj z %1$s do odblokowywania aplikacji"
+ "Wybierz PIN"
+ "Potwierdź PIN"
+ "Zablokuj %1$s, aby zwiększyć bezpieczeństwo swoich czatów.
+
+Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz tego PINU, zostaniesz wylogowany z aplikacji."
+ "Nie możesz wybrać tego PINU ze względów bezpieczeństwa"
+ "Wybierz inny kod PIN"
+ "Wprowadź ten sam kod PIN dwa razy"
+ "PINY nie pasują do siebie"
+ "Aby kontynuować, zaloguj się ponownie i utwórz nowy kod PIN"
+ "Trwa wylogowywanie"
+
+ - "Masz %1$d próbę, żeby odblokować"
+ - "Masz %1$d próby, żeby odblokować"
+ - "Masz %1$d prób, żeby odblokować"
+
+
+ - "Błędny PIN. Pozostała %1$d próba"
+ - "Błędny PIN. Pozostały %1$d próby"
+ - "Błędny PIN. Pozostało %1$d prób"
+
+ "Użyj biometrii"
+ "Użyj kodu PIN"
+ "Wylogowywanie…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..b7af1aba25
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,22 @@
+
+
+ "Esqueceu o PIN?"
+ "Mudar código de PIN"
+ "Permitir desbloqueio biométrico"
+ "Remover PIN"
+ "Tem certeza de que quer remover o PIN?"
+ "Remover PIN?"
+ "Escolher PIN"
+ "Confirmar PIN"
+ "Os PINs não correspondem"
+ "Você está sendo desconectado"
+
+ - "Você tem %1$d tentativa de debloqueio"
+ - "Você tem %1$d tentativas de debloqueio"
+
+
+ - "PIN incorreto. Você tem mais %1$d chance"
+ - "PIN incorreto. Você tem mais %1$d chances"
+
+ "Saindo…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-pt/translations.xml b/features/lockscreen/impl/src/main/res/values-pt/translations.xml
index ab87d61955..b767f0266a 100644
--- a/features/lockscreen/impl/src/main/res/values-pt/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-pt/translations.xml
@@ -14,11 +14,11 @@
"Poupa tempo e utiliza %1$s para desbloquear a aplicação"
"Escolher PIN"
"Confirmar PIN"
- "Não podes escolher este código PIN por razões de segurança"
- "Escolhe um PIN diferente"
"Bloqueia a %1$s para dar mais segurança às tuas conversas.
Escolhe algo memorável. Se te esqueceres deste PIN, a tua sessão será terminada."
+ "Não podes escolher este código PIN por razões de segurança"
+ "Escolhe um PIN diferente"
"Insere o mesmo PIN duas vezes"
"Os PINs não coincidem"
"Terás de voltar a iniciar sessão e criar um novo PIN para continuar"
diff --git a/features/lockscreen/impl/src/main/res/values-ro/translations.xml b/features/lockscreen/impl/src/main/res/values-ro/translations.xml
index 06298d6ad3..69ecc93689 100644
--- a/features/lockscreen/impl/src/main/res/values-ro/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-ro/translations.xml
@@ -14,11 +14,11 @@
"Economisiți timp și utilizați %1$s pentru a debloca aplicația de fiecare dată."
"Alegeți codul PIN"
"Confirmare PIN"
- "Nu puteți alege acest cod PIN din motive de securitate"
- "Alegeți un alt cod PIN"
"Blocați %1$s pentru a adăuga un plus de securitate la conversațiile dvs.
Alegeți ceva memorabil. Dacă uitați acest PIN, veți fi deconectat din aplicație."
+ "Nu puteți alege acest cod PIN din motive de securitate"
+ "Alegeți un alt cod PIN"
"Vă rugăm să introduceți același cod PIN de două ori"
"Codurile PIN nu corespund"
"Va trebui să vă reconectați și să creați un cod PIN nou pentru a continua"
diff --git a/features/lockscreen/impl/src/main/res/values-ru/translations.xml b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
index 07d4e4d3a6..dd9deb0ee1 100644
--- a/features/lockscreen/impl/src/main/res/values-ru/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
@@ -14,11 +14,11 @@
"Сэкономьте время и используйте %1$s для разблокировки приложения"
"Выберите PIN-код"
"Подтвердите PIN-код"
- "Из соображений безопасности вы не можешь выбрать это в качестве PIN-кода"
- "Выберите другой PIN-код"
"Заблокируйте %1$s, чтобы повысить безопасность ваших чатов.
Введите что-нибудь незабываемое. Если вы забудете этот PIN-код, вы выйдете из приложения."
+ "Из соображений безопасности вы не можешь выбрать это в качестве PIN-кода"
+ "Выберите другой PIN-код"
"Повторите PIN-код"
"PIN-коды не совпадают"
"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код"
diff --git a/features/lockscreen/impl/src/main/res/values-sk/translations.xml b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
index 7d78fa9fe0..7333d2a53f 100644
--- a/features/lockscreen/impl/src/main/res/values-sk/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
@@ -14,11 +14,11 @@
"Ušetrite si čas a použite zakaždým %1$s na odomknutie aplikácie"
"Vyberte PIN"
"Potvrdiť PIN"
- "Z bezpečnostných dôvodov si nemôžete toto zvoliť ako svoj PIN kód."
- "Vyberte iný PIN"
"Uzamknite %1$s, aby ste zvýšili bezpečnosť svojich konverzácií.
Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplikácie odhlásení."
+ "Z bezpečnostných dôvodov si nemôžete toto zvoliť ako svoj PIN kód."
+ "Vyberte iný PIN"
"Zadajte prosím ten istý PIN dvakrát"
"PIN kódy sa nezhodujú"
"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."
diff --git a/features/lockscreen/impl/src/main/res/values-sv/translations.xml b/features/lockscreen/impl/src/main/res/values-sv/translations.xml
index 14a2faffd9..d022e6b953 100644
--- a/features/lockscreen/impl/src/main/res/values-sv/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-sv/translations.xml
@@ -14,11 +14,11 @@
"Bespara dig själv lite tid och använd %1$s för att låsa upp appen varje gång"
"Välj PIN-kod"
"Bekräfta PIN-kod"
- "Du kan inte välja detta som din PIN-kod av säkerhetsskäl"
- "Välj en annan PIN-kod"
"Lås %1$s för att lägga till extra säkerhet i dina chattar.
Välj något minnesvärt. Om du glömmer den här PIN-koden loggas du ut från appen."
+ "Du kan inte välja detta som din PIN-kod av säkerhetsskäl"
+ "Välj en annan PIN-kod"
"Ange samma PIN-kod två gånger"
"PIN-koder matchar inte"
"Du måste logga in igen och skapa en ny PIN-kod för att fortsätta"
diff --git a/features/lockscreen/impl/src/main/res/values-uk/translations.xml b/features/lockscreen/impl/src/main/res/values-uk/translations.xml
index c56d4e3d70..e97b0211ae 100644
--- a/features/lockscreen/impl/src/main/res/values-uk/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-uk/translations.xml
@@ -14,11 +14,11 @@
"Заощаджуйте час і використовуйте %1$s для розблокування застосунку щоразу"
"Виберіть PIN-код"
"Підтвердити PIN-код"
- "Ви не можете вибрати його як свій PIN-код з міркувань безпеки"
- "Виберіть інший PIN-код"
"Заблокуйте %1$s, щоб додати додаткову безпеку вашим чатам.
Виберіть щось, що запам\'ятовується. Але якщо ви забудете PIN-код, ви вийдете з застосунку."
+ "Ви не можете вибрати його як свій PIN-код з міркувань безпеки"
+ "Виберіть інший PIN-код"
"Будь ласка, введіть один і той самий PIN-код двічі"
"PIN-коди не збігаються"
"Щоб продовжити, вам потрібно буде повторно увійти та створити новий PIN-код"
diff --git a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
index 757604c4f4..48fc86718c 100644
--- a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
@@ -13,11 +13,11 @@
"我想使用 PIN 碼"
"選擇 PIN 碼"
"確認 PIN 碼"
- "基於安全性的考量,您選的 PIN 碼無法使用"
- "選擇不一樣的 PIN 碼"
"將 %1$s 上鎖,為你的聊天室添加一層防護。
請選擇好記憶的數字。如果忘記 PIN 碼,您會被登出。"
+ "基於安全性的考量,您選的 PIN 碼無法使用"
+ "選擇不一樣的 PIN 碼"
"請輸入相同的 PIN 碼兩次"
"PIN 碼不一樣"
"您需要重新登入並建立新的 PIN 碼才能繼續"
diff --git a/features/lockscreen/impl/src/main/res/values-zh/translations.xml b/features/lockscreen/impl/src/main/res/values-zh/translations.xml
index 9d19208c6a..6fc69042a9 100644
--- a/features/lockscreen/impl/src/main/res/values-zh/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-zh/translations.xml
@@ -14,11 +14,11 @@
"节省时间,用 %1$s 来解锁应用程序"
"选择 PIN 码"
"确认 PIN 码"
- "出于安全原因,您不能选择这个 PIN 码"
- "选择不同的 PIN 码"
"锁定 %1$s 以为聊天增加安全性。
选择好记的 PIN 码。如果忘掉了这个 PIN 码,就不得不登出应用。"
+ "出于安全原因,您不能选择这个 PIN 码"
+ "选择不同的 PIN 码"
"请输入两次相同的 PIN 码"
"PIN 码不匹配"
"您需要重新登录并创建新的 PIN 才能继续"
diff --git a/features/lockscreen/impl/src/main/res/values/localazy.xml b/features/lockscreen/impl/src/main/res/values/localazy.xml
index 8f0a3def88..0836efa99b 100644
--- a/features/lockscreen/impl/src/main/res/values/localazy.xml
+++ b/features/lockscreen/impl/src/main/res/values/localazy.xml
@@ -14,11 +14,11 @@
"Save yourself some time and use %1$s to unlock the app each time"
"Choose PIN"
"Confirm PIN"
- "You cannot choose this as your PIN code for security reasons"
- "Choose a different PIN"
"Lock %1$s to add extra security to your chats.
Choose something memorable. If you forget this PIN, you will be logged out of the app."
+ "You cannot choose this as your PIN code for security reasons"
+ "Choose a different PIN"
"Please enter the same PIN twice"
"PINs don\'t match"
"You’ll need to re-login and create a new PIN to proceed"
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt
index aa575eabd4..692e565b8b 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt
@@ -16,13 +16,13 @@
package io.element.android.features.lockscreen.impl.fixtures
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
internal fun aLockScreenConfig(
isPinMandatory: Boolean = false,
- pinBlacklist: Set = emptySet(),
+ forbiddenPinCodes: Set = emptySet(),
pinSize: Int = 4,
maxPinCodeAttemptsBeforeLogout: Int = 3,
gracePeriod: Duration = 3.seconds,
@@ -31,7 +31,7 @@ internal fun aLockScreenConfig(
): LockScreenConfig {
return LockScreenConfig(
isPinMandatory = isPinMandatory,
- pinBlacklist = pinBlacklist,
+ forbiddenPinCodes = forbiddenPinCodes,
pinSize = pinSize,
maxPinCodeAttemptsBeforeLogout = maxPinCodeAttemptsBeforeLogout,
gracePeriod = gracePeriod,
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
index c3358c471c..46baf620f4 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
@@ -20,7 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
index 36774d9ef4..3261aa9e71 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
@@ -20,7 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
@@ -37,7 +37,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
class SetupPinPresenterTest {
- private val blacklistedPin = "1234"
+ private val forbiddenPin = "1234"
private val halfCompletePin = "12"
private val completePin = "1235"
private val mismatchedPin = "1236"
@@ -66,11 +66,11 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse()
- state.onPinEntryChanged(blacklistedPin)
+ state.onPinEntryChanged(forbiddenPin)
}
awaitLastSequentialItem().also { state ->
- state.choosePinEntry.assertText(blacklistedPin)
- assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinBlacklisted)
+ state.choosePinEntry.assertText(forbiddenPin)
+ assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.ForbiddenPin)
state.eventSink(SetupPinEvents.ClearFailure)
}
awaitLastSequentialItem().also { state ->
@@ -89,7 +89,7 @@ class SetupPinPresenterTest {
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertText(mismatchedPin)
- assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDontMatch)
+ assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDoNotMatch)
state.eventSink(SetupPinEvents.ClearFailure)
}
awaitLastSequentialItem().also { state ->
@@ -122,7 +122,7 @@ class SetupPinPresenterTest {
private fun createSetupPinPresenter(
callback: PinCodeManager.Callback,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(
- pinBlacklist = setOf(blacklistedPin)
+ forbiddenPinCodes = setOf(forbiddenPin)
),
): SetupPinPresenter {
val pinCodeManager = aPinCodeManager()
diff --git a/features/login/impl/src/main/res/values-ka/translations.xml b/features/login/impl/src/main/res/values-ka/translations.xml
index 2187fc9e95..84e97e0a34 100644
--- a/features/login/impl/src/main/res/values-ka/translations.xml
+++ b/features/login/impl/src/main/res/values-ka/translations.xml
@@ -27,6 +27,7 @@
"Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის."
"კეთილი იყოს თქვენი მობრძანება!"
"შესვლა %1$s-ში"
+ "ხელახლა ცდა"
"შეცვალეთ ანგარიშის მომწოდებელი"
"კერძო სერვერი Element-ის თანამშრომლებისთვის."
"Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის."
diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..8adfbcb8a8
--- /dev/null
+++ b/features/login/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,43 @@
+
+
+ "Zmień dostawcę konta"
+ "Adres serwera domowego"
+ "Wprowadź wyszukiwane hasło lub adres domeny."
+ "Szukaj serwera firmowego, społeczności lub prywatnego."
+ "Znajdź dostawcę konta"
+ "Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."
+ "Zamierzasz się zalogować %s"
+ "Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."
+ "Zamierzasz założyć konto na %s"
+ "Matrix.org jest ogromnym i darmowym serwerem na publicznej sieci Matrix zapewniający bezpieczną i zdecentralizowaną komunikację zarządzaną przez Fundację Matrix.org."
+ "Inne"
+ "Użyj innego dostawcy konta, takiego jak własny serwer lub konta służbowego."
+ "Zmień dostawcę konta"
+ "Nie mogliśmy połączyć się z tym serwerem domowym. Sprawdź, czy adres URL serwera został wprowadzony poprawnie. Jeśli adres URL jest poprawny, skontaktuj się z administratorem serwera w celu uzyskania dalszej pomocy."
+ "Ten serwer obecnie nie obsługuje technologii Sliding Sync."
+ "Adres URL serwera domowego"
+ "Możesz połączyć się tylko z serwerem, który obsługuje technologię Sliding Sync. Administrator serwera domowego będzie musiał ją skonfigurować. %1$s"
+ "Jaki jest adres Twojego serwera?"
+ "Wybierz swój serwer"
+ "To konto zostało dezaktywowane."
+ "Nieprawidłowa nazwa użytkownika i/lub hasło"
+ "To nie jest prawidłowy identyfikator użytkownika. Oczekiwany format: \'@user:homeserver.org\'"
+ "Wybrany serwer domowy nie obsługuje uwierzytelniania hasłem, ani OIDC. Skontaktuj się z jego administratorem lub wybierz inny serwer domowy."
+ "Wprowadź swoje dane"
+ "Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."
+ "Witaj ponownie!"
+ "Zaloguj się do %1$s"
+ "Spróbuj ponownie"
+ "Zmień dostawcę konta"
+ "Serwer prywatny dla pracowników Element."
+ "Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."
+ "Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."
+ "Zamierzasz się zalogować do %1$s"
+ "Zamierzasz utworzyć konto na %1$s"
+ "Obecnie istnieje duże zapotrzebowanie na %1$s na %2$s. Wróć do aplikacji za kilka dni i spróbuj ponownie.
+
+Dziękujemy za Twoją cierpliwość!"
+ "Witamy w %1$s!"
+ "Już prawie gotowe!"
+ "Witamy!"
+
diff --git a/features/login/impl/src/main/res/values-pt-rBR/translations.xml b/features/login/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..4cb9b4f7b0
--- /dev/null
+++ b/features/login/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,43 @@
+
+
+ "Alterar provedor da conta"
+ "Endereço do servidor"
+ "Insira um termo de pesquisa ou um endereço de domínio."
+ "Procure uma empresa, comunidade ou servidor privado."
+ "Encontre um provedor de contas"
+ "Aqui é onde suas conversas vão ficar — assim como você usa um provedor de e-mails para manter seus e-mails."
+ "Você está prestes a entrar em %s"
+ "Aqui é onde suas conversas vão ficar — assim como você usa um provedor de e-mails para manter seus e-mails."
+ "Você está prestes a criar uma conta em %s"
+ "O Matrix.org é um grande servidor gratuito na rede pública Matrix para comunicação segura e descentralizada, administrado pela Fundação Matrix.org."
+ "Outro"
+ "Use um provedor de conta diferente, como seu próprio servidor privado ou uma conta corporativa."
+ "Alterar provedor da conta"
+ "Não conseguimos acessar esse servidor. Verifique se você inseriu a URL do servidor corretamente. Se a URL estiver correta, entre em contato com o administrador do servidor para obter mais ajuda."
+ "Este servidor atualmente não oferece suporte à tecnologia sliding sync."
+ "URL do servidor"
+ "Você só pode se conectar a um servidor existente que ofereça suporte à tecnologia sliding sync. O administrador do seu servidor precisará configurá-lo. %1$s"
+ "Qual é o endereço do seu servidor?"
+ "Selecione seu servidor"
+ "Essa conta foi desativada."
+ "Nome de usuário e/ou senha incorretos"
+ "Esse não é um identificador de usuário válido. Formato esperado: \'@usuário:servidor.org\'"
+ "O servidor selecionado não suporta senha ou login no OIDC. Entre em contato com o administrador ou escolha outro servidor."
+ "Insira seus dados"
+ "A Matrix é uma rede aberta para comunicação segura e descentralizada."
+ "Bem-vindo de volta!"
+ "Iniciar sessão em %1$s"
+ "Tente novamente"
+ "Alterar provedor da conta"
+ "Um servidor privado para funcionários do Element."
+ "A Matrix é uma rede aberta para comunicação segura e descentralizada."
+ "Aqui é onde suas conversas vão ficar — assim como você usa um provedor de e-mails para manter seus e-mails."
+ "Você está prestes a fazer login em %1$s"
+ "Você está prestes a criar uma conta em %1$s"
+ "Há uma grande demanda por %1$s sobre %2$s no momento. Volte ao aplicativo em alguns dias e tente novamente.
+
+Obrigado pela sua paciência!"
+ "Bem-vindo ao %1$s!"
+ "Você está quase lá."
+ "Você está dentro."
+
diff --git a/features/logout/impl/src/main/res/values-ka/translations.xml b/features/logout/impl/src/main/res/values-ka/translations.xml
index 2226fd240f..adf15b8e46 100644
--- a/features/logout/impl/src/main/res/values-ka/translations.xml
+++ b/features/logout/impl/src/main/res/values-ka/translations.xml
@@ -4,5 +4,15 @@
"გამოსვლა"
"გამოსვლა"
"გასვლა…"
+ "თქვენ აპირებთ გასვლას თქვენი ბოლო სესიიდან. თუ ახლა გამოხვალთ, დაკარგავთ წვდომას თქვენს დაშიფრულ შეტყობინებებზე."
+ "თქვენ გამორთეთ სარეზერვო ასლი"
+ "თქვენი გასაღებების სარეზერვო ასლის შექმნა მიმდინარეობდა იმ დროს, როდესაც გამოხვედით. დაკავშირდით ისევ ისე, რომ სარეზერვო ასლი შეიქმნას ანგარიშიდან გამოსვლის გარეშე."
+ "თქვენი გასაღებების სარეზერვო ასლი ჯერ კიდევ შექმნის პროცესშია"
+ "გთხოვთ დაელოდეთ ამის დასრულებას სისტემიდან გამოსვლამდე."
+ "თქვენი გასაღებების სარეზერვო ასლი ჯერ კიდევ შექმნის პროცესშია"
"გამოსვლა"
+ "თქვენ აპირებთ გასვლას თქვენი ბოლო სესიიდან. თუ ახლა გამოხვალთ, დაკარგავთ წვდომას თქვენს დაშიფრულ შეტყობინებებზე."
+ "აღდგენა არ არის დაყენებული"
+ "თქვენ აპირებთ გასვლას თქვენი ბოლო სესიიდან. თუ ახლა გამოხვალთ, შესაძლოა დაკარგოთ წვდომა თქვენს დაშიფრულ შეტყობინებებზე."
+ "შეინახეთ თქვენი აღდგენის გასაღები?"
diff --git a/features/logout/impl/src/main/res/values-pl/translations.xml b/features/logout/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..ca0d9dc4af
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Czy na pewno chcesz się wylogować?"
+ "Wyloguj się"
+ "Wyloguj się"
+ "Wylogowywanie…"
+ "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
+ "Wyłączyłeś backup"
+ "Twoje klucze były nadal archiwizowane po przejściu w tryb offline. Połącz się ponownie, aby zapisać w chmurze przed wylogowaniem."
+ "Twoje klucze są nadal archiwizowane"
+ "Zanim się wylogujesz, poczekaj na zakończenie operacji."
+ "Twoje klucze są nadal archiwizowane"
+ "Wyloguj się"
+ "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
+ "Nie ustawiono przywracania"
+ "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
+ "Czy zapisałeś swój klucz przywracania?"
+
diff --git a/features/logout/impl/src/main/res/values-pt-rBR/translations.xml b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..65ccf4ca6f
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Você tem certeza que deseja sair?"
+ "Sair"
+ "Sair"
+ "Saindo…"
+ "Sair"
+
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index 132b8020da..4fd59be6cb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -73,8 +73,8 @@ import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCa
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
-import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
-import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
+import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList
@@ -94,7 +94,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val analyticsService: AnalyticsService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
- mentionSpanProviderFactory: MentionSpanProvider.Factory,
+ private val mentionSpanTheme: MentionSpanTheme,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Messages,
@@ -107,6 +107,7 @@ class MessagesFlowNode @AssistedInject constructor(
plugins = plugins
) {
data class Inputs(val focusedEventId: EventId?) : NodeInputs
+
private val inputs = inputs()
sealed interface NavTarget : Parcelable {
@@ -148,9 +149,7 @@ class MessagesFlowNode @AssistedInject constructor(
data class EditPoll(val eventId: EventId) : NavTarget
}
- private val callback = plugins().firstOrNull()
-
- private val mentionSpanProvider = mentionSpanProviderFactory.create(room.sessionId.value)
+ private val callbacks = plugins()
override fun onBuilt() {
super.onBuilt()
@@ -167,7 +166,7 @@ class MessagesFlowNode @AssistedInject constructor(
is NavTarget.Messages -> {
val callback = object : MessagesNode.Callback {
override fun onRoomDetailsClick() {
- callback?.onRoomDetailsClick()
+ callbacks.forEach { it.onRoomDetailsClick() }
}
override fun onEventClick(event: TimelineItem.Event): Boolean {
@@ -179,11 +178,11 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onUserDataClick(userId: UserId) {
- callback?.onUserDataClick(userId)
+ callbacks.forEach { it.onUserDataClick(userId) }
}
override fun onPermalinkClick(data: PermalinkData) {
- callback?.onPermalinkClick(data)
+ callbacks.forEach { it.onPermalinkClick(data) }
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
@@ -250,7 +249,7 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId)
val callback = object : ForwardMessagesNode.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) {
- this@MessagesFlowNode.callback?.onForwardedToSingleRoom(roomId)
+ callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
createNode(buildContext, listOf(inputs, callback))
@@ -370,11 +369,10 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
- mentionSpanProvider.updateStyles()
-
+ mentionSpanTheme.updateStyles(currentUserId = room.sessionId)
CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
- LocalMentionSpanProvider provides mentionSpanProvider,
+ LocalMentionSpanTheme provides mentionSpanTheme,
) {
BackstackWithOverlayBox(modifier)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index d1e3f87fe3..b2ee1053a6 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -78,7 +78,7 @@ class MessagesNode @AssistedInject constructor(
private val timelineController: TimelineController,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
- private val callback = plugins().firstOrNull()
+ private val callbacks = plugins()
data class Inputs(val focusedEventId: EventId?) : NodeInputs
@@ -113,19 +113,25 @@ class MessagesNode @AssistedInject constructor(
}
private fun onRoomDetailsClick() {
- callback?.onRoomDetailsClick()
+ callbacks.forEach { it.onRoomDetailsClick() }
}
private fun onEventClick(event: TimelineItem.Event): Boolean {
- return callback?.onEventClick(event).orFalse()
+ // Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
+ // - if callbacks is empty, it will return true and we want to return false.
+ // - if a callback returns false, the other callback will not be invoked.
+ return callbacks.takeIf { it.isNotEmpty() }
+ ?.map { it.onEventClick(event) }
+ ?.all { it }
+ .orFalse()
}
private fun onPreviewAttachments(attachments: ImmutableList) {
- callback?.onPreviewAttachments(attachments)
+ callbacks.forEach { it.onPreviewAttachments(attachments) }
}
private fun onUserDataClick(userId: UserId) {
- callback?.onUserDataClick(userId)
+ callbacks.forEach { it.onUserDataClick(userId) }
}
private fun onLinkClick(
@@ -137,7 +143,7 @@ class MessagesNode @AssistedInject constructor(
is PermalinkData.UserLink -> {
// Open the room member profile, it will fallback to
// the user profile if the user is not in the room
- callback?.onUserDataClick(permalink.userId)
+ callbacks.forEach { it.onUserDataClick(permalink.userId) }
}
is PermalinkData.RoomLink -> {
handleRoomLinkClick(permalink, eventSink)
@@ -159,36 +165,36 @@ class MessagesNode @AssistedInject constructor(
context.toast("Already viewing this room!")
}
} else {
- callback?.onPermalinkClick(roomLink)
+ callbacks.forEach { it.onPermalinkClick(roomLink) }
}
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
- callback?.onShowEventDebugInfoClick(eventId, debugInfo)
+ callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) }
}
override fun onForwardEventClick(eventId: EventId) {
- callback?.onForwardEventClick(eventId)
+ callbacks.forEach { it.onForwardEventClick(eventId) }
}
override fun onReportContentClick(eventId: EventId, senderId: UserId) {
- callback?.onReportMessage(eventId, senderId)
+ callbacks.forEach { it.onReportMessage(eventId, senderId) }
}
override fun onEditPollClick(eventId: EventId) {
- callback?.onEditPollClick(eventId)
+ callbacks.forEach { it.onEditPollClick(eventId) }
}
private fun onSendLocationClick() {
- callback?.onSendLocationClick()
+ callbacks.forEach { it.onSendLocationClick() }
}
private fun onCreatePollClick() {
- callback?.onCreatePollClick()
+ callbacks.forEach { it.onCreatePollClick() }
}
private fun onJoinCallClick() {
- callback?.onJoinCallClick(room.roomId)
+ callbacks.forEach { it.onJoinCallClick(room.roomId) }
}
@Composable
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index fc8a8c3975..b13a95d4f2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall
@@ -162,7 +163,7 @@ class MessagesPresenter @AssistedInject constructor(
var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.textEditorState.hasFocus(), syncUpdateFlow.value) {
withContext(dispatchers.io) {
- showReinvitePrompt = !hasDismissedInviteDialog && composerState.textEditorState.hasFocus() && room.isDirect && room.activeMemberCount == 1L
+ showReinvitePrompt = !hasDismissedInviteDialog && composerState.textEditorState.hasFocus() && room.isDm && room.activeMemberCount == 1L
}
}
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 9a80985bd0..d0e50d114c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -42,6 +42,7 @@ import io.element.android.features.messages.impl.attachments.preview.error.sendA
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
+import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -52,11 +53,15 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
+import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.timeline.TimelineException
+import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
@@ -65,7 +70,8 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
-import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message
@@ -76,6 +82,7 @@ import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEdito
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import io.element.android.wysiwyg.compose.RichTextEditorState
+import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
@@ -115,6 +122,9 @@ class MessageComposerPresenter @Inject constructor(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val timelineController: TimelineController,
private val draftService: ComposerDraftService,
+ private val mentionSpanProvider: MentionSpanProvider,
+ private val pillificationHelper: TextPillificationHelper,
+ private val roomMemberProfilesCache: RoomMemberProfilesCache,
) : Presenter {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
@@ -138,7 +148,6 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState.isReadyToProcessActions = true
}
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
-
var isMentionsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
@@ -259,8 +268,6 @@ class MessageComposerPresenter @Inject constructor(
}
}
- val mentionSpanProvider = LocalMentionSpanProvider.current
-
fun handleEvents(event: MessageComposerEvents) {
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
@@ -385,9 +392,24 @@ class MessageComposerPresenter @Inject constructor(
}
}
+ val mentionSpanTheme = LocalMentionSpanTheme.current
+ val resolveMentionDisplay = remember(mentionSpanTheme) {
+ { text: String, url: String ->
+ val permalinkData = permalinkParser.parse(url)
+ if (permalinkData is PermalinkData.UserLink) {
+ val displayNameOrId = roomMemberProfilesCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(displayNameOrId, url)
+ mentionSpan.update(mentionSpanTheme)
+ TextDisplay.Custom(mentionSpan)
+ } else {
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
+ mentionSpan.update(mentionSpanTheme)
+ TextDisplay.Custom(mentionSpan)
+ }
+ }
+ }
return MessageComposerState(
textEditorState = textEditorState,
- permalinkParser = permalinkParser,
isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
@@ -396,7 +418,8 @@ class MessageComposerPresenter @Inject constructor(
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(),
- eventSink = { handleEvents(it) }
+ resolveMentionDisplay = resolveMentionDisplay,
+ eventSink = { handleEvents(it) },
)
}
@@ -414,7 +437,14 @@ class MessageComposerPresenter @Inject constructor(
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
timelineController.invokeOnCurrentTimeline {
+ // First try to edit the message in the current timeline
editMessage(eventId, transactionId, message.markdown, message.html, message.mentions)
+ .onFailure { cause ->
+ if (cause is TimelineException.EventNotFound && eventId != null) {
+ // if the event is not found in the timeline, try to edit the message directly
+ room.editMessage(eventId, message.markdown, message.html, message.mentions)
+ }
+ }
}
}
@@ -626,7 +656,8 @@ class MessageComposerPresenter @Inject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
} else {
val markdown = richTextEditorState.messageMarkdown
- markdownTextEditorState.text.update(markdown, true)
+ val pilliefiedMarkdown = pillificationHelper.pillify(markdown)
+ markdownTextEditorState.text.update(pilliefiedMarkdown, true)
// Give some time for the focus of the previous editor to be cleared
delay(100)
markdownTextEditorState.requestFocusAction()
@@ -687,7 +718,8 @@ class MessageComposerPresenter @Inject constructor(
if (content.isEmpty()) {
markdownTextEditorState.selection = IntRange.EMPTY
}
- markdownTextEditorState.text.update(content, true)
+ val pillifiedContent = pillificationHelper.pillify(content)
+ markdownTextEditorState.text.update(pillifiedContent, true)
if (requestFocus) {
markdownTextEditorState.requestFocusAction()
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index b1c7ad79b5..332a9e75f8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -19,16 +19,15 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment
-import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
@Stable
data class MessageComposerState(
val textEditorState: TextEditorState,
- val permalinkParser: PermalinkParser,
val isFullScreen: Boolean,
val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean,
@@ -37,6 +36,7 @@ data class MessageComposerState(
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList,
+ val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index 31912f21e6..824f87bb8e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -17,12 +17,11 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.matrix.api.permalink.PermalinkData
-import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -45,9 +44,6 @@ fun aMessageComposerState(
memberSuggestions: ImmutableList = persistentListOf(),
) = MessageComposerState(
textEditorState = textEditorState,
- permalinkParser = object : PermalinkParser {
- override fun parse(uriString: String): PermalinkData = TODO()
- },
isFullScreen = isFullScreen,
mode = mode,
showTextFormatting = showTextFormatting,
@@ -56,5 +52,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions,
+ resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
eventSink = {},
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
index a033791f34..6e261e8ac9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
@@ -107,7 +107,6 @@ internal fun MessageComposerView(
modifier = modifier,
state = state.textEditorState,
voiceMessageState = voiceMessageState.voiceMessageState,
- permalinkParser = state.permalinkParser,
subcomposing = subcomposing,
onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage,
@@ -122,6 +121,7 @@ internal fun MessageComposerView(
onSendVoiceMessage = onSendVoiceMessage,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onReceiveSuggestion = ::onSuggestionReceived,
+ resolveMentionDisplay = state.resolveMentionDisplay,
onError = ::onError,
onTyping = ::onTyping,
onSelectRichContent = ::sendUri,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
index 1987b46053..6eb93756fa 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
@@ -29,7 +29,8 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
-import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay
@@ -39,7 +40,9 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
-class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
+class DefaultHtmlConverterProvider @Inject constructor(
+ private val mentionSpanProvider: MentionSpanProvider,
+) : HtmlConverterProvider {
private val htmlConverter: MutableState = mutableStateOf(null)
@Composable
@@ -50,20 +53,23 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
- val mentionSpanProvider = LocalMentionSpanProvider.current
-
+ val mentionSpanTheme = LocalMentionSpanTheme.current
val context = LocalContext.current
- htmlConverter.value = remember(editorStyle, mentionSpanProvider) {
+ htmlConverter.value = remember(editorStyle, mentionSpanTheme) {
StyledHtmlConverter(
context = context,
mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay {
- return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#"))
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#")
+ mentionSpan.update(mentionSpanTheme)
+ return TextDisplay.Custom(mentionSpan)
}
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
- return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url))
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url)
+ mentionSpan.update(mentionSpanTheme)
+ return TextDisplay.Custom(mentionSpan)
}
},
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 81ef19e587..f5903f036b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -42,6 +42,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index 37febdcf50..a2ac0ac7b1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -571,7 +571,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
- content = aTimelineItemTextContent().copy(
+ content = aTimelineItemTextContent(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowForDirectRoomPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowForDirectRoomPreview.kt
index 1981bc2c39..48f6fc3a1c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowForDirectRoomPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowForDirectRoomPreview.kt
@@ -34,7 +34,7 @@ internal fun TimelineItemEventRowForDirectRoomPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = it,
- content = aTimelineItemTextContent().copy(
+ content = aTimelineItemTextContent(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
index 8ec1d0e554..d0c8f8ffaf 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt
@@ -41,6 +41,7 @@ internal fun TimelineItemEventRowTimestampPreview(
event = event.copy(
content = oldContent.copy(
body = str,
+ pillifiedBody = str,
),
reactionsState = aTimelineItemReactions(count = 0),
),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithManyReactionsPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithManyReactionsPreview.kt
index efb619bcc2..c5095705bc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithManyReactionsPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithManyReactionsPreview.kt
@@ -32,7 +32,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
- content = aTimelineItemTextContent().copy(
+ content = aTimelineItemTextContent(
body = "A couple of multi-line messages with many reactions attached." +
" One sent by me and another from someone else."
),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt
index 85ff3a77bf..00298e1ade 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt
@@ -41,9 +41,7 @@ internal fun TimelineItemEventRowWithRRPreview(
event = aTimelineItemEvent(
isMine = false,
sendState = null,
- content = aTimelineItemTextContent().copy(
- body = "A message from someone else"
- ),
+ content = aTimelineItemTextContent(body = "A message from someone else"),
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
@@ -55,9 +53,7 @@ internal fun TimelineItemEventRowWithRRPreview(
event = aTimelineItemEvent(
isMine = true,
sendState = state.sendState,
- content = aTimelineItemTextContent().copy(
- body = "A message from me"
- ),
+ content = aTimelineItemTextContent(body = "A message from me"),
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
@@ -69,9 +65,7 @@ internal fun TimelineItemEventRowWithRRPreview(
event = aTimelineItemEvent(
isMine = true,
sendState = state.sendState,
- content = aTimelineItemTextContent().copy(
- body = "A last message from me"
- ),
+ content = aTimelineItemTextContent(body = "A last message from me"),
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
index 7b6bb471c7..294503ecf0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
@@ -48,9 +48,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
event = aTimelineItemEvent(
isMine = it,
timelineItemReactions = aTimelineItemReactions(count = 0),
- content = aTimelineItemTextContent().copy(
- body = "A reply."
- ),
+ content = aTimelineItemTextContent(body = "A reply."),
inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
groupPosition = TimelineItemGroupPosition.First,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
index b9e441f84e..e691c1e549 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
@@ -41,8 +41,10 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
+import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
+import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
@@ -74,13 +76,14 @@ fun TimelineItemTextView(
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val userProfileCache = LocalRoomMemberProfilesCache.current
val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState()
- val formattedBody = remember(content.formattedBody, lastCacheUpdate) {
- content.formattedBody?.let { formattedBody ->
- updateMentionSpans(formattedBody, userProfileCache)
- formattedBody
- }
+ val mentionSpanTheme = LocalMentionSpanTheme.current
+ val formattedBody = content.formattedBody ?: content.pillifiedBody
+ val textWithMentions = remember(formattedBody, mentionSpanTheme, lastCacheUpdate) {
+ updateMentionSpans(formattedBody, userProfileCache)
+ mentionSpanTheme.updateMentionStyles(formattedBody)
+ formattedBody
}
- return SpannableString(formattedBody ?: content.body)
+ return SpannableString(textWithMentions)
}
private fun updateMentionSpans(text: CharSequence, cache: RoomMemberProfilesCache): Boolean {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt
index 5f9a2ea428..bba17d7f9d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt
@@ -47,7 +47,7 @@ class ReactionSummaryPresenter @Inject constructor(
fun handleEvents(event: ReactionSummaryEvents) {
when (event) {
is ReactionSummaryEvents.ShowReactionSummary -> target.value = ReactionSummaryState.Summary(
- reactions = event.reactions,
+ reactions = event.reactions.toImmutableList(),
selectedKey = event.selectedKey,
selectedEventId = event.eventId
)
@@ -73,8 +73,8 @@ class ReactionSummaryPresenter @Inject constructor(
avatarUrl = member?.avatarUrl
)
sender.copy(user = user)
- })
- })
+ }.toImmutableList())
+ }.toImmutableList())
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt
index 1d1606e53d..1ccfdc9abf 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt
@@ -18,13 +18,14 @@ package io.element.android.features.messages.impl.timeline.components.reactionsu
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.libraries.matrix.api.core.EventId
+import kotlinx.collections.immutable.ImmutableList
data class ReactionSummaryState(
val target: Summary?,
val eventSink: (ReactionSummaryEvents) -> Unit
) {
data class Summary(
- val reactions: List,
+ val reactions: ImmutableList,
val selectedKey: String,
val selectedEventId: EventId
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index 5c0623b480..9d9542d595 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -36,6 +36,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -69,6 +70,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
private val featureFlagService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser,
+ private val textPillificationHelper: TextPillificationHelper,
) {
suspend fun create(
content: MessageContent,
@@ -126,6 +128,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
+ pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null,
plainText = body,
formattedBody = null,
@@ -215,6 +218,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
+ pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited,
@@ -224,6 +228,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
+ pillifiedBody = textPillificationHelper.pillify(body),
htmlDocument = null,
formattedBody = body.withLinks(),
isEdited = content.isEdited,
@@ -259,27 +264,27 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
private fun CharSequence.withFixedURLSpans(): CharSequence {
- if (this !is Spannable) return this
+ val spannable = this.toSpannable()
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
- val oldURLSpans = getSpans(0, length).associateWith {
- val start = getSpanStart(it)
- val end = getSpanEnd(it)
+ val oldURLSpans = spannable.getSpans(0, length).associateWith {
+ val start = spannable.getSpanStart(it)
+ val end = spannable.getSpanEnd(it)
Pair(start, end)
}
// Find and set as URLSpans any links present in the text
- LinkifyCompat.addLinks(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
+ LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
// Restore old spans, remove new ones if there is a conflict
for ((urlSpan, location) in oldURLSpans) {
val (start, end) = location
- val addedSpans = getSpans(start, end).orEmpty()
+ val addedSpans = spannable.getSpans(start, end).orEmpty()
if (addedSpans.isNotEmpty()) {
for (span in addedSpans) {
- removeSpan(span)
+ spannable.removeSpan(span)
}
}
- setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
- return this
+ return spannable
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index aea1019f11..c5d303b3f1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -117,6 +117,7 @@ class TimelineItemEventFactory @Inject constructor(
sentTime = timeFormatter.format(date),
)
}
+ .toImmutableList()
)
}
// Sort aggregated reactions by count and then timestamp ascending, using
@@ -127,7 +128,9 @@ class TimelineItemEventFactory @Inject constructor(
compareByDescending { it.count }
.thenBy { it.senders[0].timestamp }
)
- return TimelineItemReactions(aggregatedReactions.toImmutableList())
+ return TimelineItemReactions(
+ reactions = aggregatedReactions.toImmutableList()
+ )
}
private fun MatrixTimelineItem.Event.computeReadReceiptState(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt
index 59c52ed8cf..cc6e956a1c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model
import io.element.android.libraries.core.extensions.ellipsize
import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.collections.immutable.ImmutableList
/**
* Length at which we ellipsize a reaction key for display
@@ -35,28 +36,22 @@ private const val MAX_DISPLAY_CHARS = 16
data class AggregatedReaction(
val currentUserId: UserId,
val key: String,
- val senders: List
+ val senders: ImmutableList
) {
/**
* The key to be displayed on screen.
*
* See [MAX_DISPLAY_CHARS].
*/
- val displayKey: String by lazy {
- key.ellipsize(MAX_DISPLAY_CHARS)
- }
+ val displayKey: String = key.ellipsize(MAX_DISPLAY_CHARS)
/**
* The number of users who reacted with this key.
*/
- val count: Int by lazy {
- senders.count()
- }
+ val count: Int = senders.count()
/**
* True if the reaction has (also) been sent by the current user.
*/
- val isHighlighted: Boolean by lazy {
- senders.any { it.senderId.value == currentUserId.value }
- }
+ val isHighlighted: Boolean = senders.any { it.senderId == currentUserId }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
index dcd6bb105c..087de1753a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
import java.util.Date
@@ -53,6 +54,6 @@ fun anAggregatedReaction(
return AggregatedReaction(
currentUserId = userId,
key = key,
- senders = senders
+ senders = senders.toImmutableList()
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt
index 276ee0b266..987474bf04 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt
@@ -16,10 +16,12 @@
package io.element.android.features.messages.impl.timeline.model
+import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import java.util.Date
+@Immutable
data class AggregatedReactionSender(
val senderId: UserId,
val timestamp: Date,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
index 37c02e4a90..d651c02e4c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
@@ -21,6 +21,7 @@ import org.jsoup.nodes.Document
data class TimelineItemEmoteContent(
override val body: String,
+ override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
index 9878a28402..29fa048e1f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
@@ -84,8 +84,12 @@ fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
-fun aTimelineItemTextContent() = TimelineItemTextContent(
- body = "Text",
+fun aTimelineItemTextContent(
+ body: String = "Text",
+ pillifiedBody: CharSequence = body,
+) = TimelineItemTextContent(
+ body = body,
+ pillifiedBody = pillifiedBody,
htmlDocument = null,
formattedBody = null,
isEdited = false,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt
index e9358370a3..535a463024 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt
@@ -21,6 +21,7 @@ import org.jsoup.nodes.Document
data class TimelineItemNoticeContent(
override val body: String,
+ override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
index a0ece96855..86ee842b85 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
@@ -19,13 +19,30 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import org.jsoup.nodes.Document
+/**
+ * Represents a text based content of a timeline item event (a message, a notice, an emote event...).
+ */
@Immutable
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
+ /** The raw body of the event, in Markdown format. */
val body: String
+
+ /** The body of the event, with mentions replaced by their pillified version. */
+ val pillifiedBody: CharSequence
+
+ /** The parsed HTML DOM of the formatted event body. */
val htmlDocument: Document?
+
+ /** The formatted body of the event, already parsed and with the DOM translated to Android spans. */
val formattedBody: CharSequence?
+
+ /** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */
val plainText: String
+
+ /** Whether the event has been edited. */
val isEdited: Boolean
+
+ /** The raw HTML body of the event. */
val htmlBody: String?
get() = htmlDocument?.body()?.html()
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt
index dc4728f300..b70cc7cbd9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt
@@ -21,6 +21,7 @@ import org.jsoup.nodes.Document
data class TimelineItemTextContent(
override val body: String,
+ override val pillifiedBody: CharSequence = body,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt
new file mode 100644
index 0000000000..0e403dcb00
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.utils
+
+import android.text.Spannable
+import android.text.SpannableStringBuilder
+import androidx.core.text.getSpans
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.MatrixPatternType
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
+import io.element.android.libraries.textcomposer.mentions.MentionSpan
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import javax.inject.Inject
+
+interface TextPillificationHelper {
+ fun pillify(text: CharSequence): CharSequence
+}
+
+@ContributesBinding(RoomScope::class)
+class DefaultTextPillificationHelper @Inject constructor(
+ private val mentionSpanProvider: MentionSpanProvider,
+ private val permalinkBuilder: PermalinkBuilder,
+ private val permalinkParser: PermalinkParser,
+ private val roomMemberProfilesCache: RoomMemberProfilesCache,
+) : TextPillificationHelper {
+ @Suppress("LoopWithTooManyJumpStatements")
+ override fun pillify(text: CharSequence): CharSequence {
+ val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end }
+ if (matches.isEmpty()) return text
+
+ val spannable = SpannableStringBuilder(text)
+ for (match in matches) {
+ when (match.type) {
+ MatrixPatternType.USER_ID -> {
+ val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty()
+ if (!mentionSpanExists) {
+ val userId = UserId(match.value)
+ val permalink = permalinkBuilder.permalinkForUser(userId).getOrNull() ?: continue
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
+ roomMemberProfilesCache.getDisplayName(userId)?.let { mentionSpan.text = it }
+ spannable.replace(match.start, match.end, "@ ")
+ spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ }
+ MatrixPatternType.ROOM_ALIAS -> {
+ val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty()
+ if (!mentionSpanExists) {
+ val permalink = permalinkBuilder.permalinkForRoomAlias(RoomAlias(match.value)).getOrNull() ?: continue
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, permalink)
+ spannable.replace(match.start, match.end, "@ ")
+ spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ }
+ MatrixPatternType.AT_ROOM -> {
+ val mentionSpanExists = spannable.getSpans(match.start, match.end).isNotEmpty()
+ if (!mentionSpanExists) {
+ val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "")
+ spannable.replace(match.start, match.end, "@ ")
+ spannable.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ }
+ else -> Unit
+ }
+ }
+ return spannable
+ }
+}
diff --git a/features/messages/impl/src/main/res/values-ka/translations.xml b/features/messages/impl/src/main/res/values-ka/translations.xml
index 080696776f..47261affcc 100644
--- a/features/messages/impl/src/main/res/values-ka/translations.xml
+++ b/features/messages/impl/src/main/res/values-ka/translations.xml
@@ -21,8 +21,10 @@
"გამოკითხვა"
"ტექსტის ფორმატირება"
"შეტყობინებების ისტორია ამჟამად მიუწვდომელია."
+ "შეტყობინებების ისტორია ამ ოთახში მიუწვდომელია. დაადასტურეთ ეს მოწყობილობა თქვენი შეტყობინებების ისტორიის სანახავად."
"გსურთ მათი კვლავ მოწვევა?"
"თქვენ მარტო ხართ ამ ჩატში"
+ "მთელი ოთახისათვის შეტყობინება"
"ყველა"
"Ხელახლა გაგზავნა"
"თქვენი შეტყობინების გაგზავნა ვერ მოხერხდა"
diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..ebb2b54369
--- /dev/null
+++ b/features/messages/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Aktywności"
+ "Flagi"
+ "Jedzenie i napoje"
+ "Zwierzęta i natura"
+ "Obiekty"
+ "Buźki i osoby"
+ "Podróż i miejsca"
+ "Symbole"
+ "Zablokuj użytkownika"
+ "Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."
+ "Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."
+ "Powód zgłoszenia treści"
+ "Kamera"
+ "Zrób zdjęcie"
+ "Nagraj film"
+ "Załącznik"
+ "Zdjęcia i filmy"
+ "Lokalizacja"
+ "Ankieta"
+ "Formatowanie tekstu"
+ "Historia wiadomości jest obecnie niedostępna."
+ "Historia wiadomości jest niedostępna w tym pokoju. Zweryfikuj to urządzenie, aby zobaczyć historię wiadomości."
+ "Czy chcesz zaprosić ich z powrotem?"
+ "Jesteś sam na tym czacie"
+ "Powiadom cały pokój"
+ "Wszyscy"
+ "Wyślij ponownie"
+ "Nie udało się wysłać wiadomości"
+ "Dodaj emoji"
+ "To jest początek %1$s"
+ "To jest początek tej konwersacji"
+ "Pokaż mniej"
+ "Skopiowano wiadomość"
+ "Nie masz uprawnień, aby pisać w tym pokoju"
+ "Pokaż mniej"
+ "Pokaż więcej"
+ "Nowe"
+
+ - "%1$d zmiana pokoju"
+ - "%1$d zmian pokoju"
+ - "%1$d zmiany pokoju"
+
+
+ - "%1$s piszę"
+ - "%1$s piszą"
+ - "%1$s piszą"
+
+
diff --git a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..6d324fd695
--- /dev/null
+++ b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,42 @@
+
+
+ "Atividades"
+ "Bandeiras"
+ "Comida & Bebida"
+ "Animais & Natureza"
+ "Objetos"
+ "Sorrisos & Pessoas"
+ "Viagens & Lugares"
+ "Símbolos"
+ "Bloquear usuário"
+ "Marque se você deseja ocultar todas as mensagens atuais e futuras desse usuário"
+ "Essa mensagem será reportada ao administrador do seu homeserver. Eles não conseguirão ler nenhuma mensagem criptografada."
+ "Motivo para denunciar este conteúdo"
+ "Câmera"
+ "Tirar foto"
+ "Gravar vídeo"
+ "Anexo"
+ "Biblioteca de fotos e vídeos"
+ "Localização"
+ "Enquete"
+ "Formatação de texto"
+ "O histórico de mensagens não está disponível no momento."
+ "Gostaria de convidá-los de volta?"
+ "Você está sozinho neste chat"
+ "Todos"
+ "Enviar novamente"
+ "Sua mensagem não foi enviada"
+ "Adicionar emoji"
+ "Este é o início do %1$s."
+ "Este é o início desta conversa."
+ "Mostrar menos"
+ "Mensagem copiada"
+ "Você não tem permissão para postar nesta sala"
+ "Mostrar menos"
+ "Mostrar mais"
+ "Novo"
+
+ - "%1$d mudança de sala"
+ - "%1$d mudanças de salas"
+
+
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 74cffb30fd..091305893e 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -44,6 +44,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
+import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
@@ -64,6 +65,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -82,6 +84,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
@@ -93,6 +96,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -100,6 +104,7 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -133,7 +138,7 @@ class MessagesPresenterTest {
assertThat(initialState.roomAvatar)
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
assertThat(initialState.userHasPermissionToSendMessage).isTrue()
- assertThat(initialState.userHasPermissionToRedactOwn).isFalse()
+ assertThat(initialState.userHasPermissionToRedactOwn).isTrue()
assertThat(initialState.hasNetworkConnection).isTrue()
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
@@ -144,7 +149,13 @@ class MessagesPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - check that the room's unread flag is removed`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -158,8 +169,13 @@ class MessagesPresenterTest {
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenCanUserJoinCall(Result.success(false))
+ val room = FakeMatrixRoom(
+ canUserJoinCallResult = { Result.success(false) },
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ ).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createMessagesPresenter(matrixRoom = room)
@@ -180,7 +196,14 @@ class MessagesPresenterTest {
val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess
}
- val room = FakeMatrixRoom(liveTimeline = timeline)
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -210,7 +233,14 @@ class MessagesPresenterTest {
val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess
}
- val room = FakeMatrixRoom(liveTimeline = timeline)
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -263,6 +293,11 @@ class MessagesPresenterTest {
val event = aMessageEvent()
val matrixRoom = FakeMatrixRoom(
eventPermalinkResult = { Result.success("a link") },
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
)
val presenter = createMessagesPresenter(
clipboardHelper = clipboardHelper,
@@ -445,7 +480,14 @@ class MessagesPresenterTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val liveTimeline = FakeTimeline()
- val matrixRoom = FakeMatrixRoom(liveTimeline = liveTimeline)
+ val matrixRoom = FakeMatrixRoom(
+ liveTimeline = liveTimeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
liveTimeline.redactEventLambda = redactEventLambda
@@ -510,7 +552,16 @@ class MessagesPresenterTest {
@Test
fun `present - shows prompt to reinvite users in DM`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = true, activeMemberCount = 1L)
+ val room = FakeMatrixRoom(
+ sessionId = A_SESSION_ID,
+ isDirect = true,
+ activeMemberCount = 1L,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -536,7 +587,16 @@ class MessagesPresenterTest {
@Test
fun `present - doesn't show reinvite prompt in non-direct room`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = false, activeMemberCount = 1L)
+ val room = FakeMatrixRoom(
+ sessionId = A_SESSION_ID,
+ isDirect = false,
+ activeMemberCount = 1L,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -551,7 +611,16 @@ class MessagesPresenterTest {
@Test
fun `present - doesn't show reinvite prompt if other party is present`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = true, activeMemberCount = 2L)
+ val room = FakeMatrixRoom(
+ sessionId = A_SESSION_ID,
+ isDirect = true,
+ activeMemberCount = 2L,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -566,7 +635,16 @@ class MessagesPresenterTest {
@Test
fun `present - handle reinviting other user when memberlist is ready`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
+ val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
+ val room = FakeMatrixRoom(
+ sessionId = A_SESSION_ID,
+ inviteUserResult = inviteUserResult,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
persistentListOf(
@@ -586,13 +664,22 @@ class MessagesPresenterTest {
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
val newState = awaitItem()
assertThat(newState.inviteProgress.isSuccess()).isTrue()
- assertThat(room.invitedUserId).isEqualTo(A_SESSION_ID_2)
+ inviteUserResult.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
}
}
@Test
fun `present - handle reinviting other user when memberlist is error`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
+ val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
+ val room = FakeMatrixRoom(
+ sessionId = A_SESSION_ID,
+ inviteUserResult = inviteUserResult,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
room.givenRoomMembersState(
MatrixRoomMembersState.Error(
failure = Throwable(),
@@ -615,13 +702,20 @@ class MessagesPresenterTest {
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
val newState = awaitItem()
assertThat(newState.inviteProgress.isSuccess()).isTrue()
- assertThat(room.invitedUserId).isEqualTo(A_SESSION_ID_2)
+ inviteUserResult.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
}
}
@Test
fun `present - handle reinviting other user when memberlist is not ready`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
+ val room = FakeMatrixRoom(
+ sessionId = A_SESSION_ID,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -639,7 +733,15 @@ class MessagesPresenterTest {
@Test
fun `present - handle reinviting other user when inviting fails`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
+ val room = FakeMatrixRoom(
+ sessionId = A_SESSION_ID,
+ inviteUserResult = { Result.failure(Throwable("Oops!")) },
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
persistentListOf(
@@ -648,7 +750,6 @@ class MessagesPresenterTest {
)
)
)
- room.givenInviteUserResult(Result.failure(Throwable("Oops!")))
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -668,8 +769,19 @@ class MessagesPresenterTest {
@Test
fun `present - permission to post`() = runTest {
- val matrixRoom = FakeMatrixRoom()
- matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(true))
+ val matrixRoom = FakeMatrixRoom(
+ canUserSendMessageResult = { _, messageEventType ->
+ when (messageEventType) {
+ MessageEventType.ROOM_MESSAGE -> Result.success(true)
+ MessageEventType.REACTION -> Result.success(true)
+ else -> lambdaError()
+ }
+ },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -681,8 +793,19 @@ class MessagesPresenterTest {
@Test
fun `present - no permission to post`() = runTest {
- val matrixRoom = FakeMatrixRoom()
- matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(false))
+ val matrixRoom = FakeMatrixRoom(
+ canUserSendMessageResult = { _, messageEventType ->
+ when (messageEventType) {
+ MessageEventType.ROOM_MESSAGE -> Result.success(false)
+ MessageEventType.REACTION -> Result.success(false)
+ else -> lambdaError()
+ }
+ },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -697,7 +820,13 @@ class MessagesPresenterTest {
@Test
fun `present - permission to redact own`() = runTest {
- val matrixRoom = FakeMatrixRoom(canRedactOwn = true)
+ val matrixRoom = FakeMatrixRoom(
+ canRedactOwnResult = { Result.success(true) },
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOtherResult = { Result.success(false) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -711,7 +840,13 @@ class MessagesPresenterTest {
@Test
fun `present - permission to redact other`() = runTest {
- val matrixRoom = FakeMatrixRoom(canRedactOther = true)
+ val matrixRoom = FakeMatrixRoom(
+ canRedactOtherResult = { Result.success(true) },
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(false) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -751,7 +886,13 @@ class MessagesPresenterTest {
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
- matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
+ matrixRoom: MatrixRoom = FakeMatrixRoom(
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ ).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
@@ -765,6 +906,7 @@ class MessagesPresenterTest {
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val appPreferencesStore = InMemoryAppPreferencesStore()
val sessionPreferencesStore = InMemorySessionPreferencesStore()
+ val mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom,
@@ -782,6 +924,9 @@ class MessagesPresenterTest {
permalinkBuilder = FakePermalinkBuilder(),
timelineController = TimelineController(matrixRoom),
draftService = FakeComposerDraftService(),
+ mentionSpanProvider = mentionSpanProvider,
+ pillificationHelper = FakeTextPillificationHelper(),
+ roomMemberProfilesCache = RoomMemberProfilesCache(),
).apply {
showTextFormatting = true
isTesting = true
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 0168fcbde5..e2fbeebe3a 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -26,7 +26,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.SendActionState
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
@@ -34,6 +36,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@@ -49,13 +52,16 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
- val room = FakeMatrixRoom()
- room.givenProgressCallbackValues(
- listOf(
+ val sendMediaResult = lambdaRecorder> {
+ Result.success(FakeMediaUploadHandler())
+ }
+ val room = FakeMatrixRoom(
+ progressCallbackValues = listOf(
Pair(0, 10),
Pair(5, 10),
Pair(10, 10)
- )
+ ),
+ sendMediaResult = sendMediaResult,
)
val presenter = createAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -70,15 +76,19 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
val successState = awaitItem()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
- assertThat(room.sendMediaCount).isEqualTo(1)
+ sendMediaResult.assertions().isCalledOnce()
}
}
@Test
fun `present - send media failure scenario`() = runTest {
- val room = FakeMatrixRoom()
val failure = MediaPreProcessor.Failure(null)
- room.givenSendMediaResult(Result.failure(failure))
+ val sendMediaResult = lambdaRecorder> {
+ Result.failure(failure)
+ }
+ val room = FakeMatrixRoom(
+ sendMediaResult = sendMediaResult,
+ )
val presenter = createAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -90,7 +100,7 @@ class AttachmentsPreviewPresenterTest {
assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing)
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure))
- assertThat(room.sendMediaCount).isEqualTo(0)
+ sendMediaResult.assertions().isCalledOnce()
failureState.eventSink(AttachmentsPreviewEvents.ClearSendState)
val clearedState = awaitItem()
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
index 95dffe2bc0..88e2da81c9 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
+import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.poll.test.pollcontent.FakePollContentStateFactory
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
@@ -62,6 +63,7 @@ internal fun TestScope.aTimelineItemsFactory(
featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(),
permalinkParser = FakePermalinkParser(),
+ textPillificationHelper = FakeTextPillificationHelper(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt
index e30011999f..ffa100d95e 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt
@@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
+import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.tests.testutils.WarmUpRule
@@ -64,7 +64,7 @@ class ForwardMessagesPresenterTest {
presenter.present()
}.test {
skipItems(1)
- val summary = aRoomSummaryDetails()
+ val summary = aRoomSummary()
presenter.onRoomSelected(listOf(summary.roomId))
val forwardingState = awaitItem()
assertThat(forwardingState.forwardAction.isLoading()).isTrue()
@@ -88,7 +88,7 @@ class ForwardMessagesPresenterTest {
presenter.present()
}.test {
skipItems(1)
- val summary = aRoomSummaryDetails()
+ val summary = aRoomSummary()
presenter.onRoomSelected(listOf(summary.roomId))
skipItems(1)
val failedForwardState = awaitItem()
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt
index b5408434de..a15ea45a6c 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt
@@ -22,11 +22,14 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -81,7 +84,12 @@ class ReportMessagePresenterTest {
@Test
fun `presenter - handle successful report and block user`() = runTest {
- val room = FakeMatrixRoom()
+ val reportContentResult = lambdaRecorder> { _, _, _ ->
+ Result.success(Unit)
+ }
+ val room = FakeMatrixRoom(
+ reportContentResult = reportContentResult
+ )
val presenter = createReportMessagePresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -92,13 +100,18 @@ class ReportMessagePresenterTest {
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Success::class.java)
- assertThat(room.reportedContentCount).isEqualTo(1)
+ reportContentResult.assertions().isCalledOnce()
}
}
@Test
fun `presenter - handle successful report`() = runTest {
- val room = FakeMatrixRoom()
+ val reportContentResult = lambdaRecorder> { _, _, _ ->
+ Result.success(Unit)
+ }
+ val room = FakeMatrixRoom(
+ reportContentResult = reportContentResult
+ )
val presenter = createReportMessagePresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -107,15 +120,18 @@ class ReportMessagePresenterTest {
initialState.eventSink(ReportMessageEvents.Report)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Success::class.java)
- assertThat(room.reportedContentCount).isEqualTo(1)
+ reportContentResult.assertions().isCalledOnce()
}
}
@Test
fun `presenter - handle failed report`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenReportContentResult(Result.failure(Exception("Failed to report content")))
+ val reportContentResult = lambdaRecorder> { _, _, _ ->
+ Result.failure(Exception("Failed to report content"))
}
+ val room = FakeMatrixRoom(
+ reportContentResult = reportContentResult
+ )
val presenter = createReportMessagePresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -125,7 +141,7 @@ class ReportMessagePresenterTest {
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java)
val resultState = awaitItem()
assertThat(resultState.result).isInstanceOf(AsyncAction.Failure::class.java)
- assertThat(room.reportedContentCount).isEqualTo(1)
+ reportContentResult.assertions().isCalledOnce()
resultState.eventSink(ReportMessageEvents.ClearError)
assertThat(awaitItem().result).isInstanceOf(AsyncAction.Uninitialized::class.java)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
index c2d5966fd0..dcb09fb7c8 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
@@ -35,23 +35,28 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineController
+import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
+import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
+import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -64,11 +69,13 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
@@ -82,6 +89,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
@@ -292,7 +300,13 @@ class MessageComposerPresenterTest {
@Test
fun `present - send message with rich text enabled`() = runTest {
- val presenter = createPresenter(this)
+ val presenter = createPresenter(
+ coroutineScope = this,
+ room = FakeMatrixRoom(
+ sendMessageResult = { _, _, _ -> Result.success(Unit) },
+ typingNoticeResult = { Result.success(Unit) }
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.textEditorState.messageHtml()) { state }
@@ -318,8 +332,15 @@ class MessageComposerPresenterTest {
@Test
fun `present - send message with plain text enabled`() = runTest {
- val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("") })
- val presenter = createPresenter(this, isRichTextEditorEnabled = false)
+ val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") })
+ val presenter = createPresenter(
+ coroutineScope = this,
+ isRichTextEditorEnabled = false,
+ room = FakeMatrixRoom(
+ sendMessageResult = { _, _, _ -> Result.success(Unit) },
+ typingNoticeResult = { Result.success(Unit) }
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder)
@@ -353,7 +374,10 @@ class MessageComposerPresenterTest {
val timeline = FakeTimeline().apply {
this.editMessageLambda = editMessageLambda
}
- val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
+ val fakeMatrixRoom = FakeMatrixRoom(
+ liveTimeline = timeline,
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(
this,
fakeMatrixRoom,
@@ -394,6 +418,67 @@ class MessageComposerPresenterTest {
}
}
+ @Test
+ fun `present - edit sent message event not found`() = runTest {
+ val timelineEditMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
+ Result.failure(TimelineException.EventNotFound)
+ }
+ val timeline = FakeTimeline().apply {
+ this.editMessageLambda = timelineEditMessageLambda
+ }
+ val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val fakeMatrixRoom = FakeMatrixRoom(
+ liveTimeline = timeline,
+ typingNoticeResult = { Result.success(Unit) }
+ ).apply {
+ this.editMessageLambda = roomEditMessageLambda
+ }
+ val presenter = createPresenter(
+ this,
+ fakeMatrixRoom,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ val state = presenter.present()
+ remember(state, state.textEditorState.messageHtml()) { state }
+ }.test {
+ val initialState = awaitFirstItem()
+ assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
+ val mode = anEditMode()
+ initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
+ val withMessageState = awaitItem()
+ assertThat(withMessageState.mode).isEqualTo(mode)
+ assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
+ withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE)
+ val withEditedMessageState = awaitItem()
+ assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE)
+ withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
+ skipItems(1)
+ val messageSentState = awaitItem()
+ assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
+
+ advanceUntilIdle()
+
+ assert(timelineEditMessageLambda)
+ .isCalledOnce()
+ .with(value(AN_EVENT_ID), value(null), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
+
+ assert(roomEditMessageLambda)
+ .isCalledOnce()
+ .with(value(AN_EVENT_ID), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any())
+
+ assertThat(analyticsService.capturedEvents).containsExactly(
+ Composer(
+ inThread = false,
+ isEditing = true,
+ isReply = false,
+ messageType = Composer.MessageType.Text,
+ )
+ )
+ }
+ }
+
@Test
fun `present - edit not sent message`() = runTest {
val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List ->
@@ -402,7 +487,10 @@ class MessageComposerPresenterTest {
val timeline = FakeTimeline().apply {
this.editMessageLambda = editMessageLambda
}
- val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
+ val fakeMatrixRoom = FakeMatrixRoom(
+ liveTimeline = timeline,
+ typingNoticeResult = { Result.success(Unit) },
+ )
val presenter = createPresenter(
this,
fakeMatrixRoom,
@@ -451,7 +539,10 @@ class MessageComposerPresenterTest {
val timeline = FakeTimeline().apply {
this.replyMessageLambda = replyMessageLambda
}
- val fakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline)
+ val fakeMatrixRoom = FakeMatrixRoom(
+ liveTimeline = timeline,
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(
this,
fakeMatrixRoom,
@@ -519,7 +610,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - Pick image from gallery`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(this, room = room)
pickerProvider.givenMimeType(MimeTypes.Images)
mediaPreProcessor.givenResult(
@@ -552,7 +645,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - Pick video from gallery`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(this, room = room)
pickerProvider.givenMimeType(MimeTypes.Videos)
mediaPreProcessor.givenResult(
@@ -602,13 +697,17 @@ class MessageComposerPresenterTest {
@Test
fun `present - Pick file from storage`() = runTest {
- val room = FakeMatrixRoom()
- room.givenProgressCallbackValues(
- listOf(
+ val sendMediaResult = lambdaRecorder { _: ProgressCallback? ->
+ Result.success(FakeMediaUploadHandler())
+ }
+ val room = FakeMatrixRoom(
+ progressCallbackValues = listOf(
Pair(0, 10),
Pair(5, 10),
Pair(10, 10)
- )
+ ),
+ sendMediaResult = sendMediaResult,
+ typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -624,13 +723,15 @@ class MessageComposerPresenterTest {
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
val sentState = awaitItem()
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
- assertThat(room.sendMediaCount).isEqualTo(1)
+ sendMediaResult.assertions().isCalledOnce()
}
}
@Test
fun `present - create poll`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -647,7 +748,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - share location`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -664,7 +767,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - Take photo`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
val presenter = createPresenter(
this,
@@ -684,7 +789,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - Take photo with permission request`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(
this,
@@ -709,7 +816,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - Record video`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
val presenter = createPresenter(
this,
@@ -729,7 +838,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - Record video with permission request`() = runTest {
- val room = FakeMatrixRoom()
+ val room = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ )
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(
this,
@@ -754,9 +865,10 @@ class MessageComposerPresenterTest {
@Test
fun `present - Uploading media failure can be recovered from`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenSendMediaResult(Result.failure(Exception()))
- }
+ val room = FakeMatrixRoom(
+ sendMediaResult = { Result.failure(Exception()) },
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -837,16 +949,17 @@ class MessageComposerPresenterTest {
val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
+ var canUserTriggerRoomNotificationResult = true
val room = FakeMatrixRoom(
isDirect = false,
- isOneToOne = false,
+ canUserTriggerRoomNotificationResult = { Result.success(canUserTriggerRoomNotificationResult) },
+ typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
MatrixRoomMembersState.Ready(
persistentListOf(currentUser, invitedUser, bob, david),
)
)
- givenCanTriggerRoomNotification(Result.success(true))
}
val flagsService = FakeFeatureFlagService(
mapOf(
@@ -886,13 +999,10 @@ class MessageComposerPresenterTest {
assertThat(awaitItem().memberSuggestions).isEmpty()
// If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
- room.givenCanTriggerRoomNotification(Result.success(false))
+ canUserTriggerRoomNotificationResult = false
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
assertThat(awaitItem().memberSuggestions)
.containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
-
- // If room is a DM, `RoomMemberSuggestion.Room` is not returned
- room.givenCanTriggerRoomNotification(Result.success(true))
}
}
@@ -904,14 +1014,16 @@ class MessageComposerPresenterTest {
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
val room = FakeMatrixRoom(
isDirect = true,
- isOneToOne = true,
+ activeMemberCount = 2,
+ isEncrypted = true,
+ canUserTriggerRoomNotificationResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
MatrixRoomMembersState.Ready(
persistentListOf(currentUser, invitedUser, bob, david),
)
)
- givenCanTriggerRoomNotification(Result.success(true))
}
val flagsService = FakeFeatureFlagService(
mapOf(
@@ -934,11 +1046,11 @@ class MessageComposerPresenterTest {
}
@Test
- fun `present - insertMention`() = runTest {
+ fun `present - insertMention for user in rich text editor`() = runTest {
val presenter = createPresenter(
coroutineScope = this,
permalinkBuilder = FakePermalinkBuilder(
- result = {
+ permalinkForUserLambda = {
Result.success("https://matrix.to/#/${A_USER_ID_2.value}")
}
)
@@ -968,7 +1080,14 @@ class MessageComposerPresenterTest {
this.replyMessageLambda = replyMessageLambda
this.editMessageLambda = editMessageLambda
}
- val room = FakeMatrixRoom(liveTimeline = timeline)
+ val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List ->
+ Result.success(Unit)
+ }
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ sendMessageResult = sendMessageResult,
+ typingNoticeResult = { Result.success(Unit) }
+ )
val presenter = createPresenter(room = room, coroutineScope = this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -988,7 +1107,8 @@ class MessageComposerPresenterTest {
advanceUntilIdle()
- assertThat(room.sendMessageMentions).isEqualTo(listOf(Mention.User(A_USER_ID)))
+ sendMessageResult.assertions().isCalledOnce()
+ .with(value(A_MESSAGE), any(), value(listOf(Mention.User(A_USER_ID))))
// Check intentional mentions on reply sent
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
@@ -1044,22 +1164,32 @@ class MessageComposerPresenterTest {
@Test
fun `present - handle typing notice event`() = runTest {
- val room = FakeMatrixRoom()
+ val typingNoticeResult = lambdaRecorder> { Result.success(Unit) }
+ val room = FakeMatrixRoom(
+ typingNoticeResult = typingNoticeResult
+ )
val presenter = createPresenter(room = room, coroutineScope = this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
- assertThat(room.typingRecord).isEmpty()
+ typingNoticeResult.assertions().isNeverCalled()
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(true))
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(false))
- assertThat(room.typingRecord).isEqualTo(listOf(true, false))
+ typingNoticeResult.assertions().isCalledExactly(2)
+ .withSequence(
+ listOf(value(true)),
+ listOf(value(false)),
+ )
}
}
@Test
fun `present - handle typing notice event when sending typing notice is disabled`() = runTest {
- val room = FakeMatrixRoom()
+ val typingNoticeResult = lambdaRecorder> { Result.success(Unit) }
+ val room = FakeMatrixRoom(
+ typingNoticeResult = typingNoticeResult
+ )
val store = InMemorySessionPreferencesStore(
isSendTypingNotificationsEnabled = false
)
@@ -1068,10 +1198,10 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitFirstItem()
- assertThat(room.typingRecord).isEmpty()
+ typingNoticeResult.assertions().isNeverCalled()
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(true))
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(false))
- assertThat(room.typingRecord).isEmpty()
+ typingNoticeResult.assertions().isNeverCalled()
}
}
@@ -1210,7 +1340,10 @@ class MessageComposerPresenterTest {
val timeline = FakeTimeline().apply {
this.loadReplyDetailsLambda = loadReplyDetailsLambda
}
- val room = FakeMatrixRoom(liveTimeline = timeline)
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ typingNoticeResult = { Result.success(Unit) },
+ )
val permalinkBuilder = FakePermalinkBuilder()
val presenter = createPresenter(
room = room,
@@ -1347,7 +1480,9 @@ class MessageComposerPresenterTest {
private fun createPresenter(
coroutineScope: CoroutineScope,
- room: MatrixRoom = FakeMatrixRoom(),
+ room: MatrixRoom = FakeMatrixRoom(
+ typingNoticeResult = { Result.success(Unit) }
+ ),
pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService,
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
@@ -1355,6 +1490,10 @@ class MessageComposerPresenterTest {
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
+ permalinkParser: PermalinkParser = FakePermalinkParser(),
+ mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkParser),
+ roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
+ textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter(
@@ -1370,10 +1509,13 @@ class MessageComposerPresenterTest {
DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
- permalinkParser = FakePermalinkParser(),
+ permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room),
draftService = draftService,
+ mentionSpanProvider = mentionSpanProvider,
+ pillificationHelper = textPillificationHelper,
+ roomMemberProfilesCache = roomMemberProfilesCache,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
index 276544a057..d40f81a10c 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
@@ -21,6 +21,8 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -32,7 +34,7 @@ class DefaultHtmlConverterProviderTest {
@Test
fun `calling provide without calling Update first should throw an exception`() {
- val provider = DefaultHtmlConverterProvider()
+ val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
val exception = runCatching { provider.provide() }.exceptionOrNull()
@@ -41,7 +43,7 @@ class DefaultHtmlConverterProviderTest {
@Test
fun `calling provide after calling Update first should return an HtmlConverter`() {
- val provider = DefaultHtmlConverterProvider()
+ val provider = DefaultHtmlConverterProvider(mentionSpanProvider = MentionSpanProvider(FakePermalinkParser()))
composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
index 58cf11c4a7..887ce88d95 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
@@ -38,9 +39,9 @@ class TimelineControllerTest {
val liveTimeline = FakeTimeline(name = "live")
val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom(
- liveTimeline = liveTimeline
+ liveTimeline = liveTimeline,
+ timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
)
- matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test {
@@ -68,8 +69,17 @@ class TimelineControllerTest {
val liveTimeline = FakeTimeline(name = "live")
val detachedTimeline1 = FakeTimeline(name = "detached 1")
val detachedTimeline2 = FakeTimeline(name = "detached 2")
+ var callNumber = 0
val matrixRoom = FakeMatrixRoom(
- liveTimeline = liveTimeline
+ liveTimeline = liveTimeline,
+ timelineFocusedOnEventResult = {
+ callNumber++
+ when (callNumber) {
+ 1 -> Result.success(detachedTimeline1)
+ 2 -> Result.success(detachedTimeline2)
+ else -> lambdaError()
+ }
+ }
)
val sut = TimelineController(matrixRoom)
@@ -77,7 +87,6 @@ class TimelineControllerTest {
awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline)
}
- matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline1))
sut.focusOnEvent(AN_EVENT_ID)
awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline1)
@@ -85,7 +94,6 @@ class TimelineControllerTest {
assertThat(detachedTimeline1.closeCounter).isEqualTo(0)
assertThat(detachedTimeline2.closeCounter).isEqualTo(0)
// Focus on another event should close the previous detached timeline
- matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline2))
sut.focusOnEvent(AN_EVENT_ID)
awaitItem().also { state ->
assertThat(state).isEqualTo(detachedTimeline2)
@@ -117,11 +125,10 @@ class TimelineControllerTest {
val liveTimeline = FakeTimeline(name = "live")
val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom(
- liveTimeline = liveTimeline
+ liveTimeline = liveTimeline,
+ timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
)
- matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
val sut = TimelineController(matrixRoom)
-
sut.activeTimelineFlow().test {
awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline)
@@ -168,9 +175,9 @@ class TimelineControllerTest {
sendMessageLambda = lambdaForDetached
}
val matrixRoom = FakeMatrixRoom(
- liveTimeline = liveTimeline
+ liveTimeline = liveTimeline,
+ timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
)
- matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID)
@@ -193,9 +200,9 @@ class TimelineControllerTest {
val liveTimeline = FakeTimeline(name = "live")
val detachedTimeline = FakeTimeline(name = "detached")
val matrixRoom = FakeMatrixRoom(
- liveTimeline = liveTimeline
+ liveTimeline = liveTimeline,
+ timelineFocusedOnEventResult = { Result.success(detachedTimeline) }
)
- matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test {
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index ad52af11ca..5a171227e7 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -133,7 +133,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
)
)
)
- val room = FakeMatrixRoom(liveTimeline = timeline)
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ )
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
val presenter = createTimelinePresenter(
timeline = timeline,
@@ -482,9 +485,9 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
)
val room = FakeMatrixRoom(
liveTimeline = liveTimeline,
- ).apply {
- givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
- }
+ timelineFocusedOnEventResult = { Result.success(detachedTimeline) },
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ )
val presenter = createTimelinePresenter(
room = room,
)
@@ -529,6 +532,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
)
)
),
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
),
timelineItemIndexer = timelineItemIndexer,
)
@@ -551,9 +555,9 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
liveTimeline = FakeTimeline(
timelineItems = flowOf(emptyList()),
),
- ).apply {
- givenTimelineFocusedOnEventResult(Result.failure(Throwable("An error")))
- },
+ timelineFocusedOnEventResult = { Result.failure(Throwable("An error")) },
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ )
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -594,7 +598,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
)
)
)
- val room = FakeMatrixRoom(liveTimeline = timeline).apply {
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Unknown)
}
@@ -626,7 +633,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
private fun TestScope.createTimelinePresenter(
timeline: Timeline = FakeTimeline(),
- room: FakeMatrixRoom = FakeMatrixRoom(liveTimeline = timeline),
+ room: FakeMatrixRoom = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) }
+ ),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt
index 1a08cfd750..bf8c1d4ad1 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt
@@ -159,10 +159,6 @@ class TimelineTextViewTest {
text = text,
rawValue = rawValue,
type = type,
- backgroundColor = 0,
- textColor = 0,
- startPadding = 0,
- endPadding = 0,
)
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index ec5571df68..ffb247ed1a 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -20,9 +20,11 @@ import android.net.Uri
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
+import android.text.SpannedString
import android.text.style.URLSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
+import androidx.core.text.toSpannable
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@@ -35,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
@@ -73,6 +76,7 @@ import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorW
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
+import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -194,7 +198,7 @@ class TimelineItemContentMessageFactoryTest {
inSpans(URLSpan("https://matrix.org")) {
append("and manually added link")
}
- }
+ }.toSpannable()
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expected }
)
@@ -609,7 +613,7 @@ class TimelineItemContentMessageFactoryTest {
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
- assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
+ (result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted"))
}
@Test
@@ -643,7 +647,8 @@ class TimelineItemContentMessageFactoryTest {
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
- assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))
+
+ (result as TimelineItemEmoteContent).formattedBody.assertSpannedEquals(SpannableString("* Bob formatted"))
}
@Test
@@ -653,7 +658,7 @@ class TimelineItemContentMessageFactoryTest {
inSpans(URLSpan("https://www.example.org")) {
append("me@matrix.org")
}
- }
+ }.toSpannable()
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expectedSpanned },
permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
@@ -668,7 +673,59 @@ class TimelineItemContentMessageFactoryTest {
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
- assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expectedSpanned)
+ (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
+ }
+
+ @Test
+ fun `a message with plain URL in a formatted body Spanned format gets linkified too`() = runTest {
+ val expectedSpanned = buildSpannedString {
+ append("Test ")
+ inSpansWithFlags(URLSpan("https://www.example.org"), flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) {
+ append("https://www.example.org")
+ }
+ }
+ val sut = createTimelineItemContentMessageFactory(
+ htmlConverterTransform = { expectedSpanned },
+ permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
+ )
+ val result = sut.create(
+ content = createMessageContent(
+ type = TextMessageType(
+ body = "Test [me@matrix.org](https://www.example.org)",
+ formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
+ )
+ ),
+ senderDisambiguatedDisplayName = "Bob",
+ eventId = AN_EVENT_ID,
+ )
+ (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
+ }
+
+ @Test
+ fun `a message with plain URL in a formatted body with plain text format gets linkified too`() = runTest {
+ val resultString = "Test https://www.example.org"
+ val expectedSpanned = buildSpannedString {
+ append("Test ")
+ inSpansWithFlags(URLSpan("https://www.example.org"), flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) {
+ append("https://www.example.org")
+ }
+ }.toSpannable()
+ val sut = createTimelineItemContentMessageFactory(
+ htmlConverterTransform = { resultString },
+ permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
+ )
+ val result = sut.create(
+ content = createMessageContent(
+ type = TextMessageType(
+ body = "Test [me@matrix.org](https://www.example.org)",
+ formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org")
+ )
+ ),
+ senderDisambiguatedDisplayName = "Bob",
+ eventId = AN_EVENT_ID,
+ )
+
+ (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned)
}
private fun createMessageContent(
@@ -697,6 +754,7 @@ class TimelineItemContentMessageFactoryTest {
featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
permalinkParser = permalinkParser,
+ textPillificationHelper = FakeTextPillificationHelper(),
)
private fun createStickerContent(
@@ -716,3 +774,40 @@ class TimelineItemContentMessageFactoryTest {
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
)
}
+
+private inline fun SpannableStringBuilder.inSpansWithFlags(span: Any, flags: Int, action: SpannableStringBuilder.() -> Unit) {
+ val start = this.length
+ action()
+ val end = this.length
+ setSpan(span, start, end, flags)
+}
+
+fun CharSequence?.assertSpannedEquals(other: CharSequence?) {
+ if (this == null && other == null) {
+ return
+ } else if (this is Spanned && other is Spanned) {
+ assertThat(this.toString()).isEqualTo(other.toString())
+ assertThat(this.length).isEqualTo(other.length)
+ val thisSpans = this.getSpans(0, this.length, Any::class.java)
+ val otherSpans = other.getSpans(0, other.length, Any::class.java)
+ if (thisSpans.size != otherSpans.size) {
+ fail("Expected ${thisSpans.size} spans, got ${otherSpans.size}")
+ }
+ thisSpans.forEachIndexed { index, span ->
+ val otherSpan = otherSpans[index]
+ // URLSpans don't have a proper `equals` implementation, so we compare the URL instead
+ if (span is URLSpan && otherSpan is URLSpan) {
+ assertThat(span.url).isEqualTo(otherSpan.url)
+ } else {
+ assertThat(span).isEqualTo(otherSpan)
+ }
+ assertThat(this.getSpanStart(span)).isEqualTo(other.getSpanStart(otherSpan))
+ assertThat(this.getSpanEnd(span)).isEqualTo(other.getSpanEnd(otherSpan))
+ assertThat(this.getSpanFlags(span)).isEqualTo(other.getSpanFlags(otherSpan))
+ }
+ } else {
+ val thisString = this?.toString() ?: "null"
+ val otherString = other?.toString() ?: "null"
+ fail("Expected Spanned, got $thisString and $otherString")
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt
new file mode 100644
index 0000000000..94d109a060
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.utils
+
+import android.net.Uri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
+import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
+import io.element.android.libraries.textcomposer.mentions.MentionSpan
+import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
+import io.element.android.libraries.textcomposer.mentions.getMentionSpans
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DefaultTextPillificationHelperTest {
+ @Test
+ fun `pillify - adds pills for user ids`() {
+ val text = "A @user:server.com"
+ val helper = aTextPillificationHelper(
+ permalinkparser = FakePermalinkParser(result = {
+ PermalinkData.UserLink(UserId("@user:server.com"))
+ }),
+ permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
+ Result.success("https://matrix.to/#/@user:server.com")
+ }),
+ )
+ val pillified = helper.pillify(text)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).hasSize(1)
+ val mentionSpan = mentionSpans.firstOrNull()
+ assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
+ assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
+ assertThat(mentionSpan?.text).isEqualTo("@user:server.com")
+ }
+
+ @Test
+ fun `pillify - uses the cached display name for user mentions`() {
+ val text = "A @user:server.com"
+ val helper = aTextPillificationHelper(
+ permalinkparser = FakePermalinkParser(result = {
+ PermalinkData.UserLink(UserId("@user:server.com"))
+ }),
+ permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = {
+ Result.success("https://matrix.to/#/@user:server.com")
+ }),
+ roomMemberProfilesCache = RoomMemberProfilesCache().apply {
+ replace(listOf(aRoomMember(userId = UserId("@user:server.com"), displayName = "Alice")))
+ },
+ )
+ val pillified = helper.pillify(text)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).hasSize(1)
+ val mentionSpan = mentionSpans.firstOrNull()
+ assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.USER)
+ assertThat(mentionSpan?.rawValue).isEqualTo("@user:server.com")
+ assertThat(mentionSpan?.text).isEqualTo("Alice")
+ }
+
+ @Test
+ fun `pillify - adds pills for room aliases`() {
+ val text = "A #room:server.com"
+ val helper = aTextPillificationHelper(
+ permalinkparser = FakePermalinkParser(result = {
+ PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com")))
+ }),
+ permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = {
+ Result.success("https://matrix.to/#/#room:server.com")
+ }),
+ )
+ val pillified = helper.pillify(text)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).hasSize(1)
+ val mentionSpan = mentionSpans.firstOrNull()
+ assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.ROOM)
+ assertThat(mentionSpan?.rawValue).isEqualTo("#room:server.com")
+ assertThat(mentionSpan?.text).isEqualTo("#room:server.com")
+ }
+
+ @Test
+ fun `pillify - adds pills for @room mentions`() {
+ val text = "An @room mention"
+ val helper = aTextPillificationHelper(permalinkparser = FakePermalinkParser(result = {
+ PermalinkData.FallbackLink(Uri.EMPTY)
+ }))
+ val pillified = helper.pillify(text)
+ val mentionSpans = pillified.getMentionSpans()
+ assertThat(mentionSpans).hasSize(1)
+ val mentionSpan = mentionSpans.firstOrNull()
+ assertThat(mentionSpan?.type).isEqualTo(MentionSpan.Type.EVERYONE)
+ assertThat(mentionSpan?.rawValue).isEqualTo("@room")
+ assertThat(mentionSpan?.text).isEqualTo("@room")
+ }
+
+ private fun aTextPillificationHelper(
+ permalinkparser: PermalinkParser = FakePermalinkParser(),
+ permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(),
+ mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(permalinkparser),
+ roomMemberProfilesCache: RoomMemberProfilesCache = RoomMemberProfilesCache(),
+ ) = DefaultTextPillificationHelper(
+ mentionSpanProvider = mentionSpanProvider,
+ permalinkBuilder = permalinkBuilder,
+ permalinkParser = permalinkparser,
+ roomMemberProfilesCache = roomMemberProfilesCache,
+ )
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt
new file mode 100644
index 0000000000..1ca8e5ae5a
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.utils
+
+class FakeTextPillificationHelper(
+ private val pillifyLambda: (CharSequence) -> CharSequence = { it }
+) : TextPillificationHelper {
+ override fun pillify(text: CharSequence): CharSequence {
+ return pillifyLambda(text)
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
index 1c08a76e3d..0e507bdecf 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
@@ -28,7 +28,9 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.features.messages.test.FakeMessageComposerContext
+import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
@@ -45,6 +47,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -63,7 +66,10 @@ class VoiceMessageComposerPresenterTest {
recordingDuration = RECORDING_DURATION
)
private val analyticsService = FakeAnalyticsService()
- private val matrixRoom = FakeMatrixRoom()
+ private val sendMediaResult = lambdaRecorder> { Result.success(FakeMediaUploadHandler()) }
+ private val matrixRoom = FakeMatrixRoom(
+ sendMediaResult = sendMediaResult
+ )
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
private val messageComposerContext = FakeMessageComposerContext()
@@ -295,7 +301,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ sendMediaResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -346,7 +352,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ sendMediaResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -369,7 +375,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ sendMediaResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -393,7 +399,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
- assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ sendMediaResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(0)
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@@ -418,13 +424,13 @@ class VoiceMessageComposerPresenterTest {
ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
- assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ sendMediaResult.assertions().isNeverCalled()
mediaPreProcessor.givenAudioResult()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ sendMediaResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -461,7 +467,7 @@ class VoiceMessageComposerPresenterTest {
assertThat(showSendFailureDialog).isFalse()
}
- assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ sendMediaResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState)
}
}
@@ -477,7 +483,7 @@ class VoiceMessageComposerPresenterTest {
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ sendMediaResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(1)
voiceRecorder.assertCalls(started = 0)
@@ -496,7 +502,7 @@ class VoiceMessageComposerPresenterTest {
val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
- assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ sendMediaResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).containsExactly(
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
)
diff --git a/features/onboarding/impl/src/main/res/values-pl/translations.xml b/features/onboarding/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..5ac4d81c27
--- /dev/null
+++ b/features/onboarding/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Zaloguj się ręcznie"
+ "Zaloguj się za pomocą kodu QR"
+ "Utwórz konto"
+ "Witamy w %1$s. Szybszy i prostszy niż kiedykolwiek."
+ "Witamy w %1$s. Doładowany, dla szybkości i prostoty."
+ "Be in your element"
+
diff --git a/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml b/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..04a26fe212
--- /dev/null
+++ b/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Iniciar sessão manualmente"
+ "Iniciar sessão com código QR"
+ "Criar conta"
+ "Bem-vindo ao mais rápido %1$s de todos os tempos. Turbinado para velocidade e simplicidade."
+ "Bem-vindo ao %1$s. Turbinado, para velocidade e simplicidade"
+
diff --git a/features/poll/impl/src/main/res/values-ka/translations.xml b/features/poll/impl/src/main/res/values-ka/translations.xml
index c2417983af..0cdbc950b8 100644
--- a/features/poll/impl/src/main/res/values-ka/translations.xml
+++ b/features/poll/impl/src/main/res/values-ka/translations.xml
@@ -4,8 +4,16 @@
"შედეგების ჩვენება მხოლოდ გამოკითხვის დასრულების შემდეგ"
"ხმების დამალვა"
"ვარიანტი %1$d"
+ "თქვენი ცვლილებები არ არის შენახული. დარწმუნებული ხართ, რომ გსურთ დაბრუნება?"
"კითხვა ან თემა"
"რას ეხება გამოკითხვა?"
"გამოკითხვის შექმნა"
+ "დარწმუნებული ხართ, რომ გსურთ ამ გამოკითხვის წაშლა?"
+ "გამოკითხვის წაშლა"
"გამოკითხვის რედაქტირება"
+ "მიმდინარე გამოკითხვები ვერ მოიძებნა."
+ "ბოლო გამოკითხვების მოძებნა ვერ მოხერხდა."
+ "მიმდინარე"
+ "წარსული"
+ "გამოკითხვები"
diff --git a/features/poll/impl/src/main/res/values-pl/translations.xml b/features/poll/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..c6797b370e
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,19 @@
+
+
+ "Dodaj opcję"
+ "Pokaż wyniki dopiero po zakończeniu ankiety"
+ "Ukryj głosy"
+ "Opcja %1$d"
+ "Twoje zmiany nie zostały zapisane. Czy na pewno chcesz wrócić?"
+ "Pytanie lub temat"
+ "Czego dotyczy ankieta?"
+ "Utwórz ankietę"
+ "Czy na pewno chcesz usunąć tę ankietę?"
+ "Usuń ankietę"
+ "Edytuj ankietę"
+ "Nie znaleziono ankiet w trakcie."
+ "Nie znaleziono ankiet."
+ "W trakcie"
+ "Przeszłe"
+ "Ankiety"
+
diff --git a/features/poll/impl/src/main/res/values-pt-rBR/translations.xml b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..058dab7d42
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,12 @@
+
+
+ "Adicionar opção"
+ "Mostrar resultados somente após o término da enquete"
+ "Ocultar votos"
+ "Opção %1$d"
+ "Pergunta ou tópico"
+ "Sobre o que é a enquete?"
+ "Criar enquete"
+ "Excluir Enquete"
+ "Editar enquete"
+
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
index 89536deaec..334ea7018b 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
@@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.matrix.test.room.SavePollInvocation
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -51,9 +50,9 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class) class CreatePollPresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
+@OptIn(ExperimentalCoroutinesApi::class)
+class CreatePollPresenterTest {
+ @get:Rule val warmUpRule = WarmUpRule()
private val pollEventId = AN_EVENT_ID
private var navUpInvocationsCount = 0
@@ -128,7 +127,13 @@ import org.junit.Test
@Test
fun `create poll sends a poll start event`() = runTest {
- val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
+ val createPollResult = lambdaRecorder, Int, PollKind, Result> { _, _, _, _ -> Result.success(Unit) }
+ val presenter = createCreatePollPresenter(
+ room = FakeMatrixRoom(
+ createPollResult = createPollResult
+ ),
+ mode = CreatePollMode.NewPoll,
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -139,15 +144,13 @@ import org.junit.Test
skipItems(3)
initial.eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
- assertThat(fakeMatrixRoom.createPollInvocations.size).isEqualTo(1)
- assertThat(fakeMatrixRoom.createPollInvocations.last()).isEqualTo(
- SavePollInvocation(
- question = "A question?",
- answers = listOf("Answer 1", "Answer 2"),
- maxSelections = 1,
- pollKind = PollKind.Disclosed
+ createPollResult.assertions().isCalledOnce()
+ .with(
+ value("A question?"),
+ value(listOf("Answer 1", "Answer 2")),
+ value(1),
+ value(PollKind.Disclosed),
)
- )
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
@@ -170,8 +173,15 @@ import org.junit.Test
@Test
fun `when poll creation fails, error is tracked`() = runTest {
val error = Exception("cause")
- fakeMatrixRoom.givenCreatePollResult(Result.failure(error))
- val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
+ val createPollResult = lambdaRecorder, Int, PollKind, Result> { _, _, _, _ ->
+ Result.failure(error)
+ }
+ val presenter = createCreatePollPresenter(
+ room = FakeMatrixRoom(
+ createPollResult = createPollResult
+ ),
+ mode = CreatePollMode.NewPoll,
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -180,7 +190,7 @@ import org.junit.Test
awaitItem().eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
awaitItem().eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
- assertThat(fakeMatrixRoom.createPollInvocations).hasSize(1)
+ createPollResult.assertions().isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
@@ -252,14 +262,22 @@ import org.junit.Test
@Test
fun `when edit poll fails, error is tracked`() = runTest {
val error = Exception("cause")
+ val editPollResult = lambdaRecorder { _: EventId, _: String, _: List, _: Int, _: PollKind ->
+ Result.failure(error)
+ }
+ val presenter = createCreatePollPresenter(
+ room = FakeMatrixRoom(
+ editPollResult = editPollResult,
+ liveTimeline = timeline,
+ ),
+ mode = CreatePollMode.EditPoll(pollEventId),
+ )
val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List, _: Int, _: PollKind ->
Result.failure(error)
}
timeline.apply {
this.editPollLambda = editPollLambda
}
- fakeMatrixRoom.givenEditPollResult(Result.failure(error))
- val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/OpenSourceLicensesProvider.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/OpenSourceLicensesProvider.kt
new file mode 100644
index 0000000000..561ad6add0
--- /dev/null
+++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/OpenSourceLicensesProvider.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.api
+
+import android.app.Activity
+
+interface OpenSourceLicensesProvider {
+ val hasOpenSourceLicenses: Boolean
+ fun navigateToOpenSourceLicenses(activity: Activity)
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt
index bc2ff7894b..691bb09ae5 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt
@@ -27,6 +27,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.preferences.api.OpenSourceLicensesProvider
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope
@@ -35,6 +36,7 @@ class AboutNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val presenter: AboutPresenter,
+ private val openSourceLicensesProvider: OpenSourceLicensesProvider,
) : Node(buildContext, plugins = plugins) {
private fun onElementLegalClick(
activity: Activity,
@@ -55,6 +57,9 @@ class AboutNode @AssistedInject constructor(
onElementLegalClick = { elementLegal ->
onElementLegalClick(activity, isDark, elementLegal)
},
+ onOpenSourceLicensesClick = {
+ openSourceLicensesProvider.navigateToOpenSourceLicenses(activity)
+ },
modifier = modifier
)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt
index 76c3054f61..60395784d4 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt
@@ -17,14 +17,18 @@
package io.element.android.features.preferences.impl.about
import androidx.compose.runtime.Composable
+import io.element.android.features.preferences.api.OpenSourceLicensesProvider
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
-class AboutPresenter @Inject constructor() : Presenter {
+class AboutPresenter @Inject constructor(
+ private val openSourceLicensesProvider: OpenSourceLicensesProvider,
+) : Presenter {
@Composable
override fun present(): AboutState {
return AboutState(
elementLegals = getAllLegals(),
+ hasOpenSourcesLicenses = openSourceLicensesProvider.hasOpenSourceLicenses,
)
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt
index cd361bd4b0..a058f3c138 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt
@@ -16,7 +16,7 @@
package io.element.android.features.preferences.impl.about
-// Do not use default value, so no member get forgotten in the presenters.
data class AboutState(
val elementLegals: List,
+ val hasOpenSourcesLicenses: Boolean,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt
index 5775cbe48c..75dbad1fd2 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt
@@ -21,10 +21,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AboutStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
- aAboutState(),
+ anAboutState(),
+ anAboutState(hasOpenSourcesLicenses = true),
)
}
-fun aAboutState() = AboutState(
+fun anAboutState(
+ hasOpenSourcesLicenses: Boolean = false,
+) = AboutState(
elementLegals = getAllLegals(),
+ hasOpenSourcesLicenses = hasOpenSourcesLicenses,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt
index 4a55217275..413ecb6df9 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun AboutView(
state: AboutState,
onElementLegalClick: (ElementLegal) -> Unit,
+ onOpenSourceLicensesClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -44,6 +45,12 @@ fun AboutView(
onClick = { onElementLegalClick(elementLegal) }
)
}
+ if (state.hasOpenSourcesLicenses) {
+ PreferenceText(
+ title = stringResource(id = CommonStrings.common_open_source_licenses),
+ onClick = onOpenSourceLicensesClick,
+ )
+ }
}
}
@@ -53,6 +60,7 @@ internal fun AboutViewPreview(@PreviewParameter(AboutStateProvider::class) state
AboutView(
state = state,
onElementLegalClick = {},
+ onOpenSourceLicensesClick = {},
onBackClick = {},
)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
index 3f1f62aafc..36f4b8f00b 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
@@ -66,7 +66,7 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
- val roomsWithUserDefinedMode: MutableState> = remember {
+ val roomsWithUserDefinedMode: MutableState> = remember {
mutableStateOf(listOf())
}
@@ -115,7 +115,7 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
.launchIn(this)
}
- private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState>) {
+ private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState>) {
roomListService.allRooms
.summaries
.onEach {
@@ -126,18 +126,18 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
private fun CoroutineScope.updateRoomsWithUserDefinedMode(
summaries: List,
- roomsWithUserDefinedMode: MutableState>
+ roomsWithUserDefinedMode: MutableState>
) = launch {
val roomWithUserDefinedRules: Set = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet()
val sortedSummaries = summaries
- .filterIsInstance()
+ .filterIsInstance()
.filter {
- val room = matrixClient.getRoom(it.details.roomId) ?: return@filter false
- roomWithUserDefinedRules.contains(it.identifier()) && isOneToOne == room.isOneToOne
+ val room = matrixClient.getRoom(it.roomId) ?: return@filter false
+ roomWithUserDefinedRules.contains(it.roomId.value) && isOneToOne == room.isOneToOne
}
// locale sensitive sorting
- .sortedWith(compareBy(Collator.getInstance()) { it.details.name })
+ .sortedWith(compareBy(Collator.getInstance()) { it.name })
roomsWithUserDefinedMode.value = sortedSummaries
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
index 68b29232b0..99d7ccbf79 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
@@ -24,7 +24,7 @@ import kotlinx.collections.immutable.ImmutableList
data class EditDefaultNotificationSettingState(
val isOneToOne: Boolean,
val mode: RoomNotificationMode?,
- val roomsWithUserDefinedMode: ImmutableList,
+ val roomsWithUserDefinedMode: ImmutableList,
val changeNotificationSettingAction: AsyncAction,
val displayMentionsOnlyDisclaimer: Boolean,
val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
index 703050227e..293375abfe 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
@@ -20,7 +20,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
-import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
import kotlinx.collections.immutable.persistentListOf
@@ -53,13 +52,11 @@ private fun anEditDefaultNotificationSettingsState(
private fun aRoomSummary(
name: String?,
-) = RoomSummary.Filled(
- aRoomSummaryDetails(
- roomId = RoomId("!roomId:domain"),
- name = name,
- avatarUrl = null,
- isDirect = false,
- lastMessage = null,
- notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
- )
+) = aRoomSummaryDetails(
+ roomId = RoomId("!roomId:domain"),
+ name = name,
+ avatarUrl = null,
+ isDirect = false,
+ lastMessage = null,
+ notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
index c69970bd3f..2adf34a98b 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
@@ -89,7 +89,7 @@ fun EditDefaultNotificationSettingView(
if (state.roomsWithUserDefinedMode.isNotEmpty()) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_edit_custom_settings_section_title)) {
state.roomsWithUserDefinedMode.forEach { summary ->
- val subtitle = when (summary.details.userDefinedNotificationMode) {
+ val subtitle = when (summary.userDefinedNotificationMode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords)
@@ -99,7 +99,7 @@ fun EditDefaultNotificationSettingView(
}
ListItem(
headlineContent = {
- val roomName = summary.details.name
+ val roomName = summary.name
Text(
text = roomName ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { roomName == null }
@@ -110,14 +110,14 @@ fun EditDefaultNotificationSettingView(
},
leadingContent = ListItemContent.Custom {
CompositeAvatar(
- avatarData = summary.details.getAvatarData(size = AvatarSize.CustomRoomNotificationSetting),
- heroes = summary.details.heroes.map { user ->
+ avatarData = summary.getAvatarData(size = AvatarSize.CustomRoomNotificationSetting),
+ heroes = summary.heroes.map { user ->
user.getAvatarData(size = AvatarSize.CustomRoomNotificationSetting)
}.toPersistentList()
)
},
onClick = {
- openRoomNotificationSettings(summary.details.roomId)
+ openRoomNotificationSettings(summary.roomId)
}
)
}
diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml
index 88e7a23abe..b49f9cfc95 100644
--- a/features/preferences/impl/src/main/res/values-cs/translations.xml
+++ b/features/preferences/impl/src/main/res/values-cs/translations.xml
@@ -13,7 +13,7 @@
"Potvrzení o přečtení"
"Pokud je vypnuto, potvrzení o přečtení se nikomu neodesílají. Stále budete dostávat potvrzení o přečtení od ostatních uživatelů."
"Sdílejte přítomnost"
- "Pokud je tato funkce vypnutá, nebudete moci odesílat ani přijímat potvrzení o přečtení ani upozornění na psaní"
+ "Pokud je tato funkce vypnutá, nebudete moci odesílat ani přijímat potvrzení o přečtení ani upozornění o psaní."
"Povolit možnost zobrazení zdroje zprávy na časové ose."
"Nemáte žádné blokované uživatele"
"Odblokovat"
diff --git a/features/preferences/impl/src/main/res/values-el/translations.xml b/features/preferences/impl/src/main/res/values-el/translations.xml
index 0a94a6c3a6..9af59929fc 100644
--- a/features/preferences/impl/src/main/res/values-el/translations.xml
+++ b/features/preferences/impl/src/main/res/values-el/translations.xml
@@ -13,11 +13,13 @@
"Αποδεικτικά ανάγνωσης"
"Εάν απενεργοποιηθεί, τα αποδεικτικά ανάγνωσης δεν θα στέλνονται σε κανέναν. Θα εξακολουθείς να λαμβάνεις αποδεικτικά ανάγνωσης από άλλους χρήστες."
"Κοινή χρήση παρουσίας"
- "Εάν απενεργοποιηθεί, δεν θα μπορείς να στέλνεις ή να λαμβάνεις αποδεικτικά ανάγνωσης ή ειδοποιήσεις πληκτρολόγησης"
+ "Εάν απενεργοποιηθεί, δεν θα μπορείς να στέλνεις ή να λαμβάνεις αποδεικτικά ανάγνωσης ή ειδοποιήσεις πληκτρολόγησης."
"Ενεργοποίησε την επιλογή για προβολή πηγής μηνυμάτων στη ροή."
+ "Δεν έχεις αποκλεισμένους χρήστες"
"Άρση αποκλεισμού"
"Θα μπορείς να δεις ξανά όλα τα μηνύματα του."
"Κατάργηση αποκλεισμού χρήστη"
+ "Άρση αποκλεισμού…"
"Εμφανιζόμενο όνομα"
"Το εμφανιζόμενο όνομά σου"
"Παρουσιάστηκε ένα άγνωστο σφάλμα και οι πληροφορίες δεν μπορούσαν να αλλάξουν."
@@ -45,7 +47,7 @@
"Αναφορές"
"Όλα"
"Αναφορές"
- "Ειδοποιήσε με για"
+ "Ειδοποίησέ με για"
"Ειδοποίηση για @room"
"Για να λαμβάνεις ειδοποιήσεις, άλλαξε το %1$s ."
"ρυθμίσεις συστήματος"
diff --git a/features/preferences/impl/src/main/res/values-et/translations.xml b/features/preferences/impl/src/main/res/values-et/translations.xml
index e7700b012a..d25928f42f 100644
--- a/features/preferences/impl/src/main/res/values-et/translations.xml
+++ b/features/preferences/impl/src/main/res/values-et/translations.xml
@@ -13,7 +13,7 @@
"Lugemisteatised"
"Kui lülitad selle valiku välja, siis mitte keegi enam ei saa sinult lugemisteatisi. Küll aga saad sina teiste kasutajate lugemisteatisi."
"Jaga oma olekut"
- "Kui see eelistus on välja lülitatud, siis sa ei saa ega saada ei lugemisteatisi ega kirjutamise teavitusi"
+ "Kui see eelistus on välja lülitatud, siis sa ei saa ega saada ei lugemisteatisi ega kirjutamise teavitusi."
"Selle eelistuse sisselülitamisel on võimalik ajajoonel vaadata sõnumite lähtekoodi."
"Sa pole ühtegi kasutajat blokeerinud"
"Eemalda blokeering"
diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml
index ccdd333135..2c50c3c83c 100644
--- a/features/preferences/impl/src/main/res/values-fr/translations.xml
+++ b/features/preferences/impl/src/main/res/values-fr/translations.xml
@@ -13,7 +13,7 @@
"Accusés de lecture"
"En cas de désactivation, vos accusés de lecture ne seront pas envoyés aux autres membres. Vous verrez toujours les accusés des autres membres."
"Partager la présence"
- "Si cette option est désactivée, vous ne pourrez ni envoyer ni recevoir de confirmations de lecture ni de notifications de saisie"
+ "Si cette option est désactivée, vous ne pourrez ni envoyer ni recevoir de confirmations de lecture ni de notifications de saisie."
"Activer cette option pour pouvoir voir la source des messages dans la discussion."
"Vous n’avez bloqué personne"
"Débloquer"
diff --git a/features/preferences/impl/src/main/res/values-it/translations.xml b/features/preferences/impl/src/main/res/values-it/translations.xml
index ea7395492c..5acfeff05b 100644
--- a/features/preferences/impl/src/main/res/values-it/translations.xml
+++ b/features/preferences/impl/src/main/res/values-it/translations.xml
@@ -13,7 +13,7 @@
"Ricevute di visualizzazione"
"Se disattivato, le tue ricevute di visualizzazione non verranno inviate a nessuno. Riceverai comunque ricevute di visualizzazione da altri utenti."
"Condividi presenza online"
- "Se disattivato, non potrai inviare o ricevere ricevute di visualizzazione o notifiche di scrittura."
+ "Se disattivato, non potrai inviare o ricevere ricevute di lettura o notifiche di scrittura."
"Attiva l\'opzione per visualizzare il codice sorgente del messaggio nella conversazione."
"Non hai utenti bloccati"
"Sblocca"
diff --git a/features/preferences/impl/src/main/res/values-ka/translations.xml b/features/preferences/impl/src/main/res/values-ka/translations.xml
index f27eac6d40..89c39c1c44 100644
--- a/features/preferences/impl/src/main/res/values-ka/translations.xml
+++ b/features/preferences/impl/src/main/res/values-ka/translations.xml
@@ -7,6 +7,7 @@
"დააყენეთ საბაზისო URL Element-ის ზარებისათვის."
"არასწორი URL, გთხოვთ, დარწმუნდეთ, რომ შეიტანეთ პროტოკოლი (http/https) და სწორი მისამართი."
"გამორთეთ მდიდარი ტექსტის რედაქტორი, რათა ხელით აკრიფოთ Markdown."
+ "ჩართეთ ოპცია რათა შეტყობინების წყაროს დროის ისტორია ნახოთ."
"განბლოკვა"
"თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას."
"Მომხმარებლის განბლოკვა"
@@ -32,6 +33,8 @@
"შეტყობინებების ჩართვა ამ მოწყობილობაზე"
"კონფიგურაცია არ გამოსწორებულა, გთხოვთ, კვლავ სცადოთ."
"ჯგუფური ჩატები"
+ "მოსაწვევები"
+ "თქვენი სახლის სერვერი არ უჭერს მხარს ამ პარამეტრს დაშიფრულ ოთახებში, ზოგიერთ ოთახში შეიძლება არ მიიღოთ შეტყობინება."
"ხსენებები"
"ყველა"
"ხსენებები"
diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..021d7595f1
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,49 @@
+
+
+ "Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu."
+ "Popraw jakość swoich rozmów"
+ "Wybierz sposób otrzymywania powiadomień"
+ "Tryb dewelopera"
+ "Włącz, aby uzyskać dostęp do funkcji dla deweloperów."
+ "Własny bazowy URL dla połączeń Element"
+ "Ustaw własny bazowy URL dla połączeń Element"
+ "Nieprawidłowy adres URL, upewnij się, że zawiera protokół (http/https) i poprawny adres."
+ "Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie."
+ "Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu."
+ "Odblokuj"
+ "Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."
+ "Odblokuj użytkownika"
+ "Wyświetlana nazwa"
+ "Twoja wyświetlana nazwa"
+ "Wystąpił nieznany błąd przez co nie można było zmienić informacji."
+ "Nie można zaktualizować profilu"
+ "Edytuj profil"
+ "Aktualizowanie profilu…"
+ "Dodatkowe ustawienia"
+ "Połączenia audio i wideo"
+ "Niezgodność konfiguracji"
+ "Uprościliśmy Ustawienia powiadomień, aby ułatwić nawigowanie między opcjami. Niektóre ustawienia, które wybrałeś mogły zniknąć, lecz są wciąż aktywne.
+
+Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz."
+ "Czaty prywatne"
+ "Ustawienia własne wybranego czatu"
+ "Wystąpił błąd podczas aktualizacji ustawienia powiadomień."
+ "Wszystkie wiadomości"
+ "Tylko wzmianki i słowa kluczowe"
+ "Na czatach prywatnych, powiadamiaj mnie przez"
+ "Na czatach grupowych powiadamiaj mnie przez"
+ "Włącz powiadomienia na tym urządzeniu"
+ "Konfiguracja nie została poprawiona, spróbuj ponownie."
+ "Czaty grupowe"
+ "Zaproszenia"
+ "Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z niektórych pokoi."
+ "Wzmianki"
+ "Wszystkie"
+ "Wzmianki"
+ "Powiadamiaj mnie przez"
+ "Powiadom mnie na @pokój"
+ "Aby otrzymywać powiadomienia, zmień swoje%1$s ."
+ "ustawienia systemowe"
+ "Powiadomienia systemowe wyłączone"
+ "Powiadomienia"
+
diff --git a/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..2aa9704981
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,40 @@
+
+
+ "Escolha como receber notificações"
+ "Modo de desenvolvedor"
+ "Habilite para ter acesso a recursos e funcionalidades para desenvolvedores."
+ "Desative o editor de rich text para digitar Markdown manualmente."
+ "Desbloquear"
+ "Você poderá ver todas as mensagens deles novamente."
+ "Desbloquear usuário"
+ "Nome de exibição"
+ "Seu nome de exibição"
+ "Um erro desconhecido foi encontrado e as informações não puderam ser alteradas."
+ "Não foi possível atualizar o perfil"
+ "Editar perfil"
+ "Atualizando o perfil…"
+ "Configurações adicionais"
+ "Chamadas de áudio e vídeo"
+ "Incompatibilidade de configuração"
+ "Simplificamos as configurações de notificações para facilitar a localização das opções. Algumas configurações personalizadas que você escolheu no passado não são mostradas aqui, mas ainda estão ativas.
+
+Se você continuar, algumas de suas configurações poderão mudar."
+ "Conversas privadas"
+ "Configuração personalizada por chat"
+ "Ocorreu um erro ao atualizar a configuração de notificação."
+ "Todas as mensagens"
+ "Somente menções e palavras-chave"
+ "Em conversas privadas, me notifique para"
+ "Em conversas em grupos, me notifique para"
+ "Ativar notificações neste dispositivo"
+ "A configuração não foi corrigida, tente novamente."
+ "Bate-papos em grupo"
+ "Menções"
+ "Todos"
+ "Menções"
+ "Me notifique para"
+ "Notifique-me em @room"
+ "configurações do sistema"
+ "Notificações do sistema desativadas"
+ "Notificações"
+
diff --git a/features/preferences/impl/src/main/res/values-pt/translations.xml b/features/preferences/impl/src/main/res/values-pt/translations.xml
index db6df3941e..5502334026 100644
--- a/features/preferences/impl/src/main/res/values-pt/translations.xml
+++ b/features/preferences/impl/src/main/res/values-pt/translations.xml
@@ -13,7 +13,7 @@
"Recibos de leitura"
"Se desativada, os teus recibos de leitura não serão enviados a ninguém. Continuas a receber recibos de leitura de outros utilizadores."
"Partilhar presença"
- "Se desativado, não poderás enviar ou receber recibos de leitura ou notificações de escrita"
+ "Se desativado, não poderás enviar ou receber recibos de leitura ou notificações de escrita."
"Ativa a opção para ver a origem da mensagem na cronologia."
"Não tens nenhum utilizador bloqueado"
"Desbloquear"
diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml
index 3d8eaa5d00..2e26f68322 100644
--- a/features/preferences/impl/src/main/res/values-sk/translations.xml
+++ b/features/preferences/impl/src/main/res/values-sk/translations.xml
@@ -13,7 +13,7 @@
"Potvrdenia o prečítaní"
"Ak je táto funkcia vypnutá, vaše potvrdenia o prečítaní sa nebudú nikomu odosielať. Stále budete dostávať potvrdenia o prečítaní od ostatných používateľov."
"Zdieľať prítomnosť"
- "Ak je vypnuté, nebudete môcť odosielať ani prijímať potvrdenia o prečítaní alebo písať upozornenia"
+ "Ak je vypnuté, nebudete môcť odosielať ani prijímať potvrdenia o prečítaní alebo upozornenia o písaní"
"Povoliť možnosť zobrazenia zdroja správy na časovej osi."
"Nemáte žiadnych blokovaných používateľov"
"Odblokovať"
diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml
index 67cbf87737..da71324b9e 100644
--- a/features/preferences/impl/src/main/res/values/localazy.xml
+++ b/features/preferences/impl/src/main/res/values/localazy.xml
@@ -13,7 +13,7 @@
"Read receipts"
"If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users."
"Share presence"
- "If turned off, you won’t be able to send or receive read receipts or typing notifications"
+ "If turned off, you won’t be able to send or receive read receipts or typing notifications."
"Enable option to view message source in the timeline."
"You have no blocked users"
"Unblock"
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt
index be2742c80e..64a209dd9e 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt
@@ -31,12 +31,25 @@ class AboutPresenterTest {
@Test
fun `present - initial state`() = runTest {
- val presenter = AboutPresenter()
+ val presenter = AboutPresenter(FakeOpenSourceLicensesProvider(hasOpenSourceLicenses = true))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.elementLegals).isEqualTo(getAllLegals())
+ assertThat(initialState.hasOpenSourcesLicenses).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - initial state, no open source licenses`() = runTest {
+ val presenter = AboutPresenter(FakeOpenSourceLicensesProvider(hasOpenSourceLicenses = false))
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.elementLegals).isEqualTo(getAllLegals())
+ assertThat(initialState.hasOpenSourcesLicenses).isFalse()
}
}
}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutViewTest.kt
new file mode 100644
index 0000000000..851fcdd8e9
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutViewTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.about
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AboutViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invokes back callback`() {
+ ensureCalledOnce { callback ->
+ rule.setAboutView(
+ anAboutState(),
+ onBackClick = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking on an item invokes the expected callback`() {
+ val state = anAboutState()
+ ensureCalledOnceWithParam(state.elementLegals.first()) { callback ->
+ rule.setAboutView(
+ state,
+ onElementLegalClick = callback,
+ )
+ rule.clickOn(state.elementLegals.first().titleRes)
+ }
+ }
+
+ @Test
+ fun `if open source licenses are not available, the entry is not displayed`() {
+ rule.setAboutView(
+ anAboutState(),
+ )
+ val text = rule.activity.getString(CommonStrings.common_open_source_licenses)
+ rule.onNodeWithText(text).assertDoesNotExist()
+ }
+
+ @Test
+ fun `if open source licenses are available, clicking on the entry invokes the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setAboutView(
+ anAboutState(
+ hasOpenSourcesLicenses = true,
+ ),
+ onOpenSourceLicensesClick = callback,
+ )
+ rule.clickOn(CommonStrings.common_open_source_licenses)
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setAboutView(
+ state: AboutState,
+ onElementLegalClick: (ElementLegal) -> Unit = EnsureNeverCalledWithParam(),
+ onOpenSourceLicensesClick: () -> Unit = EnsureNeverCalled(),
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ AboutView(
+ state = state,
+ onElementLegalClick = onElementLegalClick,
+ onOpenSourceLicensesClick = onOpenSourceLicensesClick,
+ onBackClick = onBackClick,
+ )
+ }
+}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/FakeOpenSourceLicensesProvider.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/FakeOpenSourceLicensesProvider.kt
new file mode 100644
index 0000000000..c7f1c25a33
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/FakeOpenSourceLicensesProvider.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.about
+
+import android.app.Activity
+import io.element.android.features.preferences.api.OpenSourceLicensesProvider
+
+class FakeOpenSourceLicensesProvider(
+ override val hasOpenSourceLicenses: Boolean,
+) : OpenSourceLicensesProvider {
+ override fun navigateToOpenSourceLicenses(activity: Activity) = Unit
+}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt
index 353d505e50..e7e70623f2 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt
@@ -45,7 +45,7 @@ class BlockedUserViewTest {
fun `clicking on back invokes back callback`() {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setLogoutView(
+ rule.setBlockedUsersView(
aBlockedUsersState(
eventSink = eventsRecorder
),
@@ -59,7 +59,7 @@ class BlockedUserViewTest {
fun `clicking on a user emits the expected Event`() {
val eventsRecorder = EventsRecorder()
val userList = aMatrixUserList()
- rule.setLogoutView(
+ rule.setBlockedUsersView(
aBlockedUsersState(
blockedUsers = userList,
eventSink = eventsRecorder
@@ -72,7 +72,7 @@ class BlockedUserViewTest {
@Test
fun `clicking on cancel sends a BlockedUsersEvents`() {
val eventsRecorder = EventsRecorder()
- rule.setLogoutView(
+ rule.setBlockedUsersView(
aBlockedUsersState(
unblockUserAction = AsyncAction.Confirming,
eventSink = eventsRecorder
@@ -85,7 +85,7 @@ class BlockedUserViewTest {
@Test
fun `clicking on confirm sends a BlockedUsersEvents`() {
val eventsRecorder = EventsRecorder()
- rule.setLogoutView(
+ rule.setBlockedUsersView(
aBlockedUsersState(
unblockUserAction = AsyncAction.Confirming,
eventSink = eventsRecorder
@@ -96,7 +96,7 @@ class BlockedUserViewTest {
}
}
-private fun AndroidComposeTestRule.setLogoutView(
+private fun AndroidComposeTestRule.setBlockedUsersView(
state: BlockedUsersState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt
index 0d5921047c..784bec715b 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt
@@ -23,13 +23,12 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
-import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
+import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
@@ -72,11 +71,11 @@ class EditDefaultNotificationSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- roomListService.postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails(notificationMode = RoomNotificationMode.ALL_MESSAGES))))
+ roomListService.postAllRooms(listOf(aRoomSummary(notificationMode = RoomNotificationMode.ALL_MESSAGES)))
val loadedState = consumeItemsUntilPredicate { state ->
- state.roomsWithUserDefinedMode.any { it.details.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }
+ state.roomsWithUserDefinedMode.any { it.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }
}.last()
- assertThat(loadedState.roomsWithUserDefinedMode.any { it.details.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue()
+ assertThat(loadedState.roomsWithUserDefinedMode.any { it.userDefinedNotificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue()
}
}
diff --git a/features/rageshake/api/src/main/res/values-pl/translations.xml b/features/rageshake/api/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..d9b42eb749
--- /dev/null
+++ b/features/rageshake/api/src/main/res/values-pl/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "%1$s uległ awarii podczas ostatniego użycia. Czy chcesz przesłać nam raport o awarii?"
+ "Wygląda na to, że potrząsasz telefonem z frustracji. Czy chcesz otworzyć ekran zgłaszania błędów?"
+ "Gniewne wstrząsanie"
+ "Próg wykrywania"
+
diff --git a/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml b/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..3985149a27
--- /dev/null
+++ b/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "%1$s fechou inesperadamente na última vez que foi usado. Gostaria de compartilhar um relatório de falhas conosco?"
+ "Você parece estar sacudindo o telefone em sinal de frustração. Você gostaria de abrir a tela de relatório de erros?"
+ "Rageshake"
+ "Limiar de deteção"
+
diff --git a/features/rageshake/impl/src/main/res/values-pl/translations.xml b/features/rageshake/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..04bf123498
--- /dev/null
+++ b/features/rageshake/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Dołącz zrzut ekranu"
+ "Możecie skontaktować się ze mną, jeśli macie jakiekolwiek dodatkowe pytania."
+ "Napisz do mnie"
+ "Edytuj zrzut ekranu"
+ "Opisz problem. Co zrobiłeś? Czego oczekiwałeś? Co się stało zamiast tego. Podaj jak najwięcej szczegółów."
+ "Opisz problem…"
+ "Jeśli to możliwe, napisz zgłoszenje w języku angielskim."
+ "Wyślij logi awarii"
+ "Zezwól na logi"
+ "Wyślij zrzut ekranu"
+ "Logi zostaną dołączone do Twojej wiadomości, aby upewnić się, że wszystko działa poprawnie. Aby wysłać wiadomość bez logów, wyłącz to ustawienie."
+ "%1$s uległ awarii podczas ostatniego użycia. Czy chcesz przesłać nam raport o awarii?"
+ "Wyświetl logi"
+
diff --git a/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..d046391f6a
--- /dev/null
+++ b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Anexar captura de tela"
+ "Você pode entrar em contato comigo se tiver alguma pergunta adicional."
+ "Entre em contato comigo"
+ "Editar captura de tela"
+ "Descreva o problema. O que você fez? O que você esperava que acontecesse? O que realmente aconteceu? Por favor, forneça o máximo de detalhes possível."
+ "Descreva o problema…"
+ "Se possível, escreva a descrição em inglês."
+ "Enviar registros de falhas"
+ "Permitir registros"
+ "Enviar captura de tela"
+ "Os registros serão incluídos com sua mensagem para garantir que tudo esteja funcionando corretamente. Para enviar sua mensagem sem registros, desative essa configuração."
+ "%1$s fechou inesperadamente na última vez que foi usado. Gostaria de compartilhar um relatório de falhas conosco?"
+
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index 50fad866d9..163e466e1f 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
index 305393c822..01e1cf99c2 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
@@ -31,6 +31,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -59,10 +60,10 @@ class RoomMemberListPresenter @AssistedInject constructor(
@Composable
override fun present(): RoomMemberListState {
val coroutineScope = rememberCoroutineScope()
- var roomMembers by remember { mutableStateOf(RoomMembers.loading()) }
+ var roomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember {
- mutableStateOf>(SearchBarResultState.Initial())
+ mutableStateOf>>(SearchBarResultState.Initial())
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
@@ -82,6 +83,12 @@ class RoomMemberListPresenter @AssistedInject constructor(
if (membersState is MatrixRoomMembersState.Unknown) {
return@LaunchedEffect
}
+ val finalMembersState = membersState
+ if (finalMembersState is MatrixRoomMembersState.Error && finalMembersState.roomMembers().orEmpty().isEmpty()) {
+ // Cannot fetch members and no cached members, display the error
+ roomMembers = AsyncData.Failure(finalMembersState.failure)
+ return@LaunchedEffect
+ }
withContext(coroutineDispatchers.io) {
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
val info = room.roomInfoFlow.first()
@@ -90,14 +97,18 @@ class RoomMemberListPresenter @AssistedInject constructor(
// This result will come from the timeline loading membership events and it'll be wrong.
return@withContext
}
- roomMembers = RoomMembers(
+ val result = RoomMembers(
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
- isLoading = membersState is MatrixRoomMembersState.Pending,
)
+ roomMembers = if (membersState is MatrixRoomMembersState.Pending) {
+ AsyncData.Loading(result)
+ } else {
+ AsyncData.Success(result)
+ }
}
}
@@ -110,15 +121,19 @@ class RoomMemberListPresenter @AssistedInject constructor(
if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} else {
+ val result = RoomMembers(
+ invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
+ joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
+ .sortedWith(PowerLevelRoomMemberComparator())
+ .toImmutableList(),
+ banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
+ )
SearchBarResultState.Results(
- RoomMembers(
- invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
- joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
- .sortedWith(PowerLevelRoomMemberComparator())
- .toImmutableList(),
- banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
- isLoading = membersState is MatrixRoomMembersState.Pending,
- )
+ if (membersState is MatrixRoomMembersState.Pending) {
+ AsyncData.Loading(result)
+ } else {
+ AsyncData.Success(result)
+ }
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
index 67a0802f02..b71da8bfd8 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
@@ -17,15 +17,15 @@
package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
data class RoomMemberListState(
- val roomMembers: RoomMembers,
+ val roomMembers: AsyncData,
val searchQuery: String,
- val searchResults: SearchBarResultState,
+ val searchResults: SearchBarResultState>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val moderationState: RoomMembersModerationState,
@@ -36,14 +36,4 @@ data class RoomMembers(
val invited: ImmutableList,
val joined: ImmutableList,
val banned: ImmutableList,
- val isLoading: Boolean,
-) {
- companion object {
- fun loading() = RoomMembers(
- invited = persistentListOf(),
- joined = persistentListOf(),
- banned = persistentListOf(),
- isLoading = true,
- )
- }
-}
+)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
index 8be4124e0d..11132040b0 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
@@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -29,14 +30,15 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider
get() = sequenceOf(
aRoomMemberListState(
- roomMembers = RoomMembers(
- invited = persistentListOf(aVictor(), aWalter()),
- joined = persistentListOf(anAlice(), aBob(), aWalter()),
- banned = persistentListOf(),
- isLoading = false,
+ roomMembers = AsyncData.Success(
+ RoomMembers(
+ invited = persistentListOf(aVictor(), aWalter()),
+ joined = persistentListOf(anAlice(), aBob(), aWalter()),
+ banned = persistentListOf(),
+ )
)
),
- aRoomMemberListState(roomMembers = RoomMembers.loading()),
+ aRoomMemberListState(roomMembers = AsyncData.Loading()),
aRoomMemberListState().copy(canInvite = true),
aRoomMemberListState().copy(isSearchActive = false),
aRoomMemberListState().copy(isSearchActive = true),
@@ -45,11 +47,12 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider
get() = sequenceOf(
aRoomMemberListState(
- roomMembers = RoomMembers(
- invited = persistentListOf(),
- joined = persistentListOf(),
- banned = persistentListOf(
- aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
- aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
- aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
- ),
- isLoading = false,
+ roomMembers = AsyncData.Success(
+ RoomMembers(
+ invited = persistentListOf(),
+ joined = persistentListOf(),
+ banned = persistentListOf(
+ aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
+ aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
+ aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
+ ),
+ )
),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
),
aRoomMemberListState(
- roomMembers = RoomMembers(
- invited = persistentListOf(),
- joined = persistentListOf(),
- banned = persistentListOf(
- aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
- aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
- aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
- ),
- isLoading = true,
+ roomMembers = AsyncData.Loading(
+ RoomMembers(
+ invited = persistentListOf(),
+ joined = persistentListOf(),
+ banned = persistentListOf(
+ aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
+ aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
+ aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
+ ),
+ )
),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
),
aRoomMemberListState(
- roomMembers = RoomMembers(
- invited = persistentListOf(),
- joined = persistentListOf(),
- banned = persistentListOf(),
- isLoading = false,
+ roomMembers = AsyncData.Success(
+ RoomMembers(
+ invited = persistentListOf(),
+ joined = persistentListOf(),
+ banned = persistentListOf(),
+ )
),
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
)
@@ -103,8 +112,8 @@ internal class RoomMemberListStateBannedProvider : PreviewParameterProvider = SearchBarResultState.Initial(),
+ roomMembers: AsyncData = AsyncData.Loading(),
+ searchResults: SearchBarResultState> = SearchBarResultState.Initial(),
moderationState: RoomMembersModerationState = aRoomMembersModerationState(),
) = RoomMemberListState(
roomMembers = roomMembers,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
index b12ecf4ec8..b0110b7039 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -128,7 +129,6 @@ fun RoomMemberListView(
if (!state.isSearchActive) {
RoomMemberList(
- isLoading = state.roomMembers.isLoading,
roomMembers = state.roomMembers,
showMembersCount = true,
canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers,
@@ -149,8 +149,7 @@ fun RoomMemberListView(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun RoomMemberList(
- isLoading: Boolean,
- roomMembers: RoomMembers,
+ roomMembers: AsyncData,
showMembersCount: Boolean,
selectedSection: SelectedSection,
onSelectedSectionChange: (SelectedSection) -> Unit,
@@ -183,7 +182,7 @@ private fun RoomMemberList(
}
}
AnimatedVisibility(
- visible = isLoading,
+ visible = roomMembers.isLoading(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
@@ -191,47 +190,72 @@ private fun RoomMemberList(
}
}
}
- when (selectedSection) {
- SelectedSection.MEMBERS -> {
- if (roomMembers.invited.isNotEmpty()) {
- roomMemberListSection(
- headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
- members = roomMembers.invited,
- onMemberSelected = { onSelectUser(it) }
- )
- }
- if (roomMembers.joined.isNotEmpty()) {
- roomMemberListSection(
- headerText = {
- if (showMembersCount) {
- val memberCount = roomMembers.joined.count()
- pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
- } else {
- stringResource(id = R.string.screen_room_member_list_room_members_header_title)
- }
- },
- members = roomMembers.joined,
- onMemberSelected = { onSelectUser(it) }
- )
- }
+ when (roomMembers) {
+ is AsyncData.Failure -> failureItem(roomMembers.error)
+ is AsyncData.Loading,
+ is AsyncData.Success -> memberItems(
+ roomMembers = roomMembers.dataOrNull() ?: return@LazyColumn,
+ selectedSection = selectedSection,
+ onSelectUser = onSelectUser,
+ showMembersCount = showMembersCount,
+ )
+ AsyncData.Uninitialized -> Unit
+ }
+ }
+}
+
+private fun LazyListScope.memberItems(
+ roomMembers: RoomMembers,
+ selectedSection: SelectedSection,
+ onSelectUser: (RoomMember) -> Unit,
+ showMembersCount: Boolean,
+) {
+ when (selectedSection) {
+ SelectedSection.MEMBERS -> {
+ if (roomMembers.invited.isNotEmpty()) {
+ roomMemberListSection(
+ headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
+ members = roomMembers.invited,
+ onMemberSelected = { onSelectUser(it) }
+ )
}
- SelectedSection.BANNED -> { // Banned users
- if (roomMembers.banned.isNotEmpty()) {
- roomMemberListSection(
- headerText = null,
- members = roomMembers.banned,
- onMemberSelected = { onSelectUser(it) }
- )
- } else {
- item {
- Box(Modifier.fillParentMaxSize().padding(horizontal = 16.dp)) {
- Text(
- modifier = Modifier.padding(bottom = 56.dp).align(Alignment.Center),
- text = stringResource(id = R.string.screen_room_member_list_banned_empty),
- color = ElementTheme.colors.textSecondary,
- textAlign = TextAlign.Center,
- )
+ if (roomMembers.joined.isNotEmpty()) {
+ roomMemberListSection(
+ headerText = {
+ if (showMembersCount) {
+ val memberCount = roomMembers.joined.count()
+ pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
+ } else {
+ stringResource(id = R.string.screen_room_member_list_room_members_header_title)
}
+ },
+ members = roomMembers.joined,
+ onMemberSelected = { onSelectUser(it) }
+ )
+ }
+ }
+ SelectedSection.BANNED -> { // Banned users
+ if (roomMembers.banned.isNotEmpty()) {
+ roomMemberListSection(
+ headerText = null,
+ members = roomMembers.banned,
+ onMemberSelected = { onSelectUser(it) }
+ )
+ } else {
+ item {
+ Box(
+ Modifier
+ .fillParentMaxSize()
+ .padding(horizontal = 16.dp)
+ ) {
+ Text(
+ modifier = Modifier
+ .padding(bottom = 56.dp)
+ .align(Alignment.Center),
+ text = stringResource(id = R.string.screen_room_member_list_banned_empty),
+ color = ElementTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
}
}
}
@@ -239,9 +263,22 @@ private fun RoomMemberList(
}
}
+private fun LazyListScope.failureItem(failure: Throwable) {
+ item {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 32.dp),
+ text = stringResource(id = CommonStrings.error_unknown) + "\n\n" + failure.localizedMessage,
+ color = ElementTheme.colors.textCriticalPrimary,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
private fun LazyListScope.roomMemberListSection(
headerText: @Composable (() -> String)?,
- members: ImmutableList,
+ members: ImmutableList?,
onMemberSelected: (RoomMember) -> Unit,
) {
headerText?.let {
@@ -254,7 +291,7 @@ private fun LazyListScope.roomMemberListSection(
)
}
}
- items(members) { matrixUser ->
+ items(members.orEmpty()) { matrixUser ->
RoomMemberListItem(
modifier = Modifier.fillMaxWidth(),
roomMember = matrixUser,
@@ -320,7 +357,7 @@ private fun RoomMemberListTopBar(
@Composable
private fun RoomMemberSearchBar(
query: String,
- state: SearchBarResultState,
+ state: SearchBarResultState>,
active: Boolean,
placeHolderTitle: String,
onActiveChange: (Boolean) -> Unit,
@@ -339,7 +376,6 @@ private fun RoomMemberSearchBar(
resultState = state,
resultHandler = { results ->
RoomMemberList(
- isLoading = false,
roomMembers = results,
showMembersCount = false,
onSelectUser = { onSelectUser(it) },
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt
index f35aa1f211..326255effd 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import io.element.android.services.analytics.api.AnalyticsService
@@ -58,8 +59,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
private suspend fun canKick() = room.canKick().getOrDefault(false)
override suspend fun canDisplayModerationActions(): Boolean {
- val isDm = room.isDm && room.isEncrypted
- return !isDm && (canBan() || canKick())
+ return !room.isDm && (canBan() || canKick())
}
@Composable
diff --git a/features/roomdetails/impl/src/main/res/values-el/translations.xml b/features/roomdetails/impl/src/main/res/values-el/translations.xml
index 9262a346c5..2c1886ce40 100644
--- a/features/roomdetails/impl/src/main/res/values-el/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml
@@ -4,6 +4,7 @@
"Ο οικιακός διακομιστής σου δεν υποστηρίζει αυτήν την επιλογή σε κρυπτογραφημένα δωμάτια, ενδέχεται να μην λάβεις ειδοποίηση σε ορισμένα δωμάτια."
"Δημοσκοπήσεις"
"Μόνο διαχειριστές"
+ "Αποκλεισμός ατόμων"
"Αφαίρεση μηνυμάτων"
"Όλοι"
"Πρόσκληση ατόμων"
@@ -58,17 +59,23 @@
"Πληροφορίες δωματίου"
"Θέμα"
"Ενημέρωση δωματίου…"
+ "Αποκλεισμός"
"Δεν θα μπορεί να συμμετέχει ξανά σε αυτό το δωμάτιο εάν προσκληθεί."
+ "Θες σίγουρα να αποκλείσεις αυτό το μέλος;"
+ "Δεν υπάρχουν αποκλεισμένοι χρήστες σε αυτό το δωμάτιο."
+ "Αποκλεισμός του χρήστη %1$s"
- "%1$d άτομο"
- "%1$d άτομα"
+ "Αφαίρεση και αποκλεισμός μέλους"
"Αφαίρεση από το δωμάτιο"
"Αφαίρεση και αποκλεισμός μέλους"
"Μόνο αφαίρεση μέλους"
"Αφαίρεση μέλους και απαγόρευση συμμετοχής στο μέλλον;"
"Αναίρεση αποκλεισμού"
"Θα μπορεί να συμμετάσχει ξανά στο δωμάτιο εάν προσκληθεί."
+ "Άρση αποκλεισμού χρήστη"
"Προβολή προφίλ"
"Αποκλεισμένοι"
"Μέλη"
@@ -77,6 +84,7 @@
"Διαχειριστής"
"Συντονιστής"
"Μέλη δωματίου"
+ "Άρση αποκλεισμού %1$s"
"Να επιτρέπεται η προσαρμοσμένη ρύθμιση"
"Η ενεργοποίηση αυτής της ρύθμισης θα παρακάμψει την προεπιλεγμένη ρύθμιση"
"Ειδοποιήσε με σε αυτήν τη συνομιλία για"
diff --git a/features/roomdetails/impl/src/main/res/values-ka/translations.xml b/features/roomdetails/impl/src/main/res/values-ka/translations.xml
index 75f7121b45..65ce26797e 100644
--- a/features/roomdetails/impl/src/main/res/values-ka/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ka/translations.xml
@@ -1,6 +1,8 @@
"შეტყობინებების პარამეტრის განახლებისას მოხდა შეცდომა."
+ "თქვენი სახლის სერვერი არ უჭერს მხარს ამ პარამეტრს დაშიფრულ ოთახებში, ზოგიერთ ოთახში შეიძლება არ მიიღოთ შეტყობინება."
+ "გამოკითხვები"
"ყველა"
"თემის დამატება"
"უკვე წევრია"
@@ -39,6 +41,7 @@
"შეტყობინებების პარამეტრების ჩატვირთვისას მოხდა შეცდომა."
"ნაგულისხმევი რეჟიმის აღდგენა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა."
"რეჟიმის დაყენება ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა."
+ "თქვენი სახლის სერვერი არ უჭერს მხარს ამ პარამეტრს დაშიფრულ ოთახებში, თქვენ არ მიიღებთ შეტყობინებას ამ ოთახში."
"ყველა შეტყობინება"
"მხოლოდ ხსენებები და საკვანძო სიტყვები"
"ამ ოთახში, შემატყობინეთ:"
diff --git a/features/roomdetails/impl/src/main/res/values-pl/translations.xml b/features/roomdetails/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..569e167d92
--- /dev/null
+++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,50 @@
+
+
+ "Wystąpił błąd podczas aktualizacji ustawienia powiadomień."
+ "Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z niektórych pokoi."
+ "Ankiety"
+ "Wszyscy"
+ "Dodaj temat"
+ "Jest już członkiem"
+ "Już zaproszony"
+ "Edytuj pokój"
+ "Wystąpił nieznany błąd i nie można było zmienić informacji."
+ "Nie można zaktualizować pokoju"
+ "Wiadomości są zabezpieczone kłódkami. Tylko Ty i odbiorcy macie unikalne klucze do ich odblokowania."
+ "Szyfrowanie wiadomości włączone"
+ "Wystąpił błąd podczas ładowania ustawień powiadomień."
+ "Wyciszenie tego pokoju nie powiodło się, spróbuj ponownie."
+ "Nie udało się wyłączyć wyciszenia tego pokoju. Spróbuj ponownie."
+ "Zaproś znajomych"
+ "Opuść rozmowę"
+ "Opuść pokój"
+ "Niestandardowy"
+ "Domyślny"
+ "Powiadomienia"
+ "Nazwa pokoju"
+ "Bezpieczeństwo"
+ "Udostępnij pokój"
+ "Temat"
+ "Aktualizuję pokój…"
+
+ - "%1$d osoba"
+ - "%1$d osoby"
+ - "%1$d osób"
+
+ "Oczekiwanie"
+ "Członkowie pokoju"
+ "Zezwalaj na ustawienia niestandardowe"
+ "Włączenie tej opcji nadpisze ustawienie domyślne"
+ "Powiadamiaj mnie o tym czacie przez"
+ "Możesz to zmienić w swoim %1$s."
+ "ustawienia globalne"
+ "Ustawienie domyślne"
+ "Usuń ustawienia własne"
+ "Wystąpił błąd podczas ładowania ustawień powiadomień."
+ "Nie udało się przywrócić trybu domyślnego, spróbuj ponownie."
+ "Nie udało się ustawić trybu, spróbuj ponownie."
+ "Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z tego pokoju."
+ "Wszystkie wiadomości"
+ "Tylko wzmianki i słowa kluczowe"
+ "W tym pokoju, powiadamiaj mnie przez"
+
diff --git a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..4989168201
--- /dev/null
+++ b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,46 @@
+
+
+ "Ocorreu um erro ao atualizar a configuração de notificação."
+ "Todos"
+ "Adicionar tópico"
+ "Já é membro"
+ "Já foi convidado"
+ "Editar sala"
+ "Ocorreu um erro desconhecido e as informações não puderam ser alteradas."
+ "Não foi possível atualizar a sala"
+ "As mensagens são protegidas com bloqueios. Somente você e os destinatários têm as chaves exclusivas para desbloqueá-los."
+ "Criptografia de mensagens ativada"
+ "Ocorreu um erro ao carregar as configurações de notificação."
+ "Falha ao silenciar esta sala, tente novamente."
+ "Falha ao ativar o som desta sala. Tente novamente."
+ "Convidar pessoas"
+ "Sair da conversa"
+ "Sair da sala"
+ "Personalizado"
+ "Padrão"
+ "Notificações"
+ "Nome da sala"
+ "Segurança"
+ "Compartilhar sala"
+ "Tópico"
+ "Atualizando a sala…"
+
+ - "%1$d pessoa"
+ - "%1$d pessoas"
+
+ "Pendente"
+ "Membros da sala"
+ "Permitir configuração personalizada"
+ "Ativar isso substituirá sua configuração padrão"
+ "Me notifique nesta conversa para"
+ "Você pode alterá-lo no seu %1$s."
+ "configurações globais"
+ "Configuração padrão"
+ "Remover configuração personalizada"
+ "Ocorreu um erro ao carregar as configurações de notificação."
+ "Falha ao restaurar o modo padrão, tente novamente."
+ "Falha ao definir o modo, tente novamente."
+ "Todas as mensagens"
+ "Somente menções e palavras-chave"
+ "Nesta sala, notifique-me para"
+
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/MatrixRoomFixture.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/MatrixRoomFixture.kt
index cffd2ae1f1..9fd89f482d 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/MatrixRoomFixture.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/MatrixRoomFixture.kt
@@ -17,11 +17,15 @@
package io.element.android.features.roomdetails
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.tests.testutils.lambda.lambdaError
fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID,
@@ -34,6 +38,16 @@ fun aMatrixRoom(
isDirect: Boolean = false,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
emitRoomInfo: Boolean = false,
+ canInviteResult: (UserId) -> Result = { lambdaError() },
+ canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() },
+ userDisplayNameResult: () -> Result = { lambdaError() },
+ userAvatarUrlResult: () -> Result = { lambdaError() },
+ setNameResult: (String) -> Result = { lambdaError() },
+ setTopicResult: (String) -> Result = { lambdaError() },
+ updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() },
+ removeAvatarResult: () -> Result = { lambdaError() },
+ canUserJoinCallResult: (UserId) -> Result = { lambdaError() },
+ getUpdatedMemberResult: (UserId) -> Result = { lambdaError() },
) = FakeMatrixRoom(
roomId = roomId,
displayName = displayName,
@@ -42,7 +56,17 @@ fun aMatrixRoom(
isEncrypted = isEncrypted,
isPublic = isPublic,
isDirect = isDirect,
- notificationSettingsService = notificationSettingsService
+ notificationSettingsService = notificationSettingsService,
+ canInviteResult = canInviteResult,
+ canSendStateResult = canSendStateResult,
+ userDisplayNameResult = userDisplayNameResult,
+ userAvatarUrlResult = userAvatarUrlResult,
+ setNameResult = setNameResult,
+ setTopicResult = setTopicResult,
+ updateAvatarResult = updateAvatarResult,
+ removeAvatarResult = removeAvatarResult,
+ canUserJoinCallResult = canUserJoinCallResult,
+ getUpdatedMemberResult = getUpdatedMemberResult,
).apply {
if (emitRoomInfo) {
givenRoomInfo(
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt
index 92ac27454a..ff89aba9be 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt
@@ -53,6 +53,9 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.FakeLifecycleOwner
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.withFakeLifecycleOwner
import kotlinx.collections.immutable.persistentListOf
@@ -110,7 +113,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state is created from room if roomInfo is null`() = runTest {
- val room = aMatrixRoom()
+ val room = aMatrixRoom(
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
val initialState = awaitItem()
@@ -128,7 +135,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state is updated with roomInfo if it exists`() = runTest {
val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg")
- val room = aMatrixRoom().apply {
+ val room = aMatrixRoom(
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ ).apply {
givenRoomInfo(roomInfo)
}
val presenter = createRoomDetailsPresenter(room)
@@ -145,7 +156,12 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state with no room name`() = runTest {
- val room = aMatrixRoom(displayName = "")
+ val room = aMatrixRoom(
+ displayName = "",
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
val initialState = awaitItem()
@@ -162,6 +178,16 @@ class RoomDetailsPresenterTest {
val room = aMatrixRoom(
isEncrypted = true,
isDirect = true,
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ getUpdatedMemberResult = { userId ->
+ when (userId) {
+ A_SESSION_ID -> Result.success(myRoomMember)
+ A_USER_ID_2 -> Result.success(otherRoomMember)
+ else -> lambdaError()
+ }
+ },
).apply {
val roomMembers = persistentListOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
@@ -181,9 +207,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can invite others to room`() = runTest {
- val room = aMatrixRoom().apply {
- givenCanInviteResult(Result.success(true))
- }
+ val room = aMatrixRoom(
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
presenter.test {
// Initially false
@@ -197,9 +225,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can not invite others to room`() = runTest {
- val room = aMatrixRoom().apply {
- givenCanInviteResult(Result.success(false))
- }
+ val room = aMatrixRoom(
+ canInviteResult = { Result.success(false) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
assertThat(awaitItem().canInvite).isFalse()
@@ -210,9 +240,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when canInvite errors`() = runTest {
- val room = aMatrixRoom().apply {
- givenCanInviteResult(Result.failure(Throwable("Whoops")))
- }
+ val room = aMatrixRoom(
+ canInviteResult = { Result.failure(Throwable("Whoops")) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
assertThat(awaitItem().canInvite).isFalse()
@@ -223,12 +255,18 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can edit one attribute`() = runTest {
- val room = aMatrixRoom().apply {
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
- givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
- givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp")))
- givenCanInviteResult(Result.success(false))
- }
+ val room = aMatrixRoom(
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_TOPIC -> Result.success(true)
+ StateEventType.ROOM_NAME -> Result.success(false)
+ StateEventType.ROOM_AVATAR -> Result.failure(Throwable("Whelp"))
+ else -> lambdaError()
+ }
+ },
+ canInviteResult = { Result.success(false) },
+ canUserJoinCallResult = { Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
// Initially false
@@ -247,14 +285,26 @@ class RoomDetailsPresenterTest {
val room = aMatrixRoom(
isEncrypted = true,
isDirect = true,
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_TOPIC -> Result.success(true)
+ StateEventType.ROOM_NAME -> Result.success(true)
+ StateEventType.ROOM_AVATAR -> Result.success(true)
+ else -> lambdaError()
+ }
+ },
+ canInviteResult = { Result.success(false) },
+ canUserJoinCallResult = { Result.success(true) },
+ getUpdatedMemberResult = { userId ->
+ when (userId) {
+ A_SESSION_ID -> Result.success(myRoomMember)
+ A_USER_ID_2 -> Result.success(otherRoomMember)
+ else -> lambdaError()
+ }
+ },
).apply {
val roomMembers = persistentListOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
-
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
- givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
- givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
- givenCanInviteResult(Result.success(false))
}
val presenter = createRoomDetailsPresenter(room)
presenter.test {
@@ -278,12 +328,28 @@ class RoomDetailsPresenterTest {
isEncrypted = true,
isDirect = true,
topic = null,
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_AVATAR,
+ StateEventType.ROOM_TOPIC,
+ StateEventType.ROOM_NAME -> Result.success(true)
+ else -> lambdaError()
+ }
+ },
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ getUpdatedMemberResult = { userId ->
+ when (userId) {
+ A_SESSION_ID -> Result.success(myRoomMember)
+ A_USER_ID_2 -> Result.success(otherRoomMember)
+ else -> lambdaError()
+ }
+ },
).apply {
val roomMembers = persistentListOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
-
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
}
+
val presenter = createRoomDetailsPresenter(room)
presenter.test {
skipItems(1)
@@ -297,12 +363,20 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can edit all attributes`() = runTest {
- val room = aMatrixRoom().apply {
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
- givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
- givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
- givenCanInviteResult(Result.success(false))
- }
+ val room = aMatrixRoom(
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_TOPIC -> Result.success(true)
+ StateEventType.ROOM_NAME -> Result.success(true)
+ StateEventType.ROOM_AVATAR -> Result.success(true)
+ else -> lambdaError()
+ }
+ },
+ canInviteResult = {
+ Result.success(false)
+ },
+ canUserJoinCallResult = { Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
// Initially false
@@ -316,12 +390,20 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can edit no attributes`() = runTest {
- val room = aMatrixRoom().apply {
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false))
- givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
- givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
- givenCanInviteResult(Result.success(false))
- }
+ val room = aMatrixRoom(
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_TOPIC -> Result.success(false)
+ StateEventType.ROOM_NAME -> Result.success(false)
+ StateEventType.ROOM_AVATAR -> Result.success(false)
+ else -> lambdaError()
+ }
+ },
+ canInviteResult = {
+ Result.success(false)
+ },
+ canUserJoinCallResult = { Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
// Initially false, and no further events
@@ -333,11 +415,21 @@ class RoomDetailsPresenterTest {
@Test
fun `present - topic state is hidden when no topic and user has no permission`() = runTest {
- val room = aMatrixRoom(topic = null).apply {
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false))
- givenCanInviteResult(Result.success(false))
- }
-
+ val room = aMatrixRoom(
+ topic = null,
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_AVATAR,
+ StateEventType.ROOM_NAME -> Result.success(true)
+ StateEventType.ROOM_TOPIC -> Result.success(false)
+ else -> lambdaError()
+ }
+ },
+ canInviteResult = {
+ Result.success(false)
+ },
+ canUserJoinCallResult = { Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room)
presenter.test {
// The initial state is "hidden" and no further state changes happen
@@ -349,12 +441,23 @@ class RoomDetailsPresenterTest {
@Test
fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest {
- val room = aMatrixRoom(topic = null).apply {
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
- givenCanInviteResult(Result.success(false))
+ val room = aMatrixRoom(
+ topic = null,
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_AVATAR,
+ StateEventType.ROOM_TOPIC,
+ StateEventType.ROOM_NAME -> Result.success(true)
+ else -> lambdaError()
+ }
+ },
+ canInviteResult = {
+ Result.success(false)
+ },
+ canUserJoinCallResult = { Result.success(true) },
+ ).apply {
givenRoomInfo(aRoomInfo(topic = null))
}
-
val presenter = createRoomDetailsPresenter(room)
presenter.test {
// Ignore the initial state
@@ -370,7 +473,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - leave room event is passed on to leave room presenter`() = runTest {
val leaveRoomPresenter = FakeLeaveRoomPresenter()
- val room = aMatrixRoom()
+ val room = aMatrixRoom(
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(
room = room,
leaveRoomPresenter = leaveRoomPresenter,
@@ -379,7 +486,11 @@ class RoomDetailsPresenterTest {
presenter.test {
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom)
- assertThat(leaveRoomPresenter.events).contains(LeaveRoomEvent.ShowConfirmation(room.roomId))
+ assertThat(leaveRoomPresenter.events).contains(
+ LeaveRoomEvent.ShowConfirmation(
+ room.roomId
+ )
+ )
cancelAndIgnoreRemainingEvents()
}
@@ -389,33 +500,54 @@ class RoomDetailsPresenterTest {
fun `present - notification mode changes`() = runTest {
val leaveRoomPresenter = FakeLeaveRoomPresenter()
val notificationSettingsService = FakeNotificationSettingsService()
- val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
+ val room = aMatrixRoom(
+ notificationSettingsService = notificationSettingsService,
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(
room = room,
leaveRoomPresenter = leaveRoomPresenter,
notificationSettingsService = notificationSettingsService,
)
presenter.test {
- notificationSettingsService.setRoomNotificationMode(room.roomId, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ notificationSettingsService.setRoomNotificationMode(
+ room.roomId,
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ )
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
- assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
+ )
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - mute room notifications`() = runTest {
- val notificationSettingsService = FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
- val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
- val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
+ val notificationSettingsService =
+ FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
+ val room = aMatrixRoom(
+ notificationSettingsService = notificationSettingsService,
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
+ val presenter = createRoomDetailsPresenter(
+ room = room,
+ notificationSettingsService = notificationSettingsService
+ )
presenter.test {
awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
}.last()
- assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MUTE)
+ assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(
+ RoomNotificationMode.MUTE
+ )
cancelAndIgnoreRemainingEvents()
}
}
@@ -426,29 +558,50 @@ class RoomDetailsPresenterTest {
initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES
)
- val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
- val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
+ val room = aMatrixRoom(
+ notificationSettingsService = notificationSettingsService,
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
+ val presenter = createRoomDetailsPresenter(
+ room = room,
+ notificationSettingsService = notificationSettingsService
+ )
presenter.test {
awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
}.last()
- assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
+ assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(
+ RoomNotificationMode.ALL_MESSAGES
+ )
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
- val room = FakeMatrixRoom()
+ val setIsFavoriteResult = lambdaRecorder> { _ -> Result.success(Unit) }
+ val room = FakeMatrixRoom(
+ setIsFavoriteResult = setIsFavoriteResult,
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val analyticsService = FakeAnalyticsService()
- val presenter = createRoomDetailsPresenter(room = room, analyticsService = analyticsService)
+ val presenter =
+ createRoomDetailsPresenter(room = room, analyticsService = analyticsService)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEvent.SetFavorite(true))
- assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true))
+ setIsFavoriteResult.assertions().isCalledOnce().with(value(true))
initialState.eventSink(RoomDetailsEvent.SetFavorite(false))
- assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true, false))
+ setIsFavoriteResult.assertions().isCalledExactly(2)
+ .withSequence(
+ listOf(value(true)),
+ listOf(value(false)),
+ )
assertThat(analyticsService.capturedEvents).containsExactly(
Interaction(name = Interaction.Name.MobileRoomFavouriteToggle),
Interaction(name = Interaction.Name.MobileRoomFavouriteToggle)
@@ -459,7 +612,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - changes in room info updates the is favorite flag`() = runTest {
- val room = aMatrixRoom()
+ val room = aMatrixRoom(
+ canInviteResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ canSendStateResult = { _, _ -> Result.success(true) },
+ )
val presenter = createRoomDetailsPresenter(room = room)
presenter.test {
room.givenRoomInfo(aRoomInfo(isFavorite = true))
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
index f287fe4ab4..5d8a43ca4b 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
@@ -39,6 +39,9 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -98,6 +101,7 @@ class RoomDetailsEditPresenterTest {
displayName = A_ROOM_NAME,
rawName = A_ROOM_RAW_NAME,
emitRoomInfo = true,
+ canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -120,11 +124,17 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - sets canChangeName if user has permission`() = runTest {
- val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply {
- givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
- givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
- }
+ val room = aMatrixRoom(
+ avatarUrl = AN_AVATAR_URL,
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_NAME -> Result.success(true)
+ StateEventType.ROOM_AVATAR -> Result.success(false)
+ StateEventType.ROOM_TOPIC -> Result.failure(Throwable("Oops"))
+ else -> lambdaError()
+ }
+ },
+ )
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -144,11 +154,17 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - sets canChangeAvatar if user has permission`() = runTest {
- val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply {
- givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
- givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
- }
+ val room = aMatrixRoom(
+ avatarUrl = AN_AVATAR_URL,
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_NAME -> Result.success(false)
+ StateEventType.ROOM_AVATAR -> Result.success(true)
+ StateEventType.ROOM_TOPIC -> Result.failure(Throwable("Oops"))
+ else -> lambdaError()
+ }
+ }
+ )
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -168,11 +184,17 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - sets canChangeTopic if user has permission`() = runTest {
- val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply {
- givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
- givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Oops")))
- givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
- }
+ val room = aMatrixRoom(
+ avatarUrl = AN_AVATAR_URL,
+ canSendStateResult = { _, stateEventType ->
+ when (stateEventType) {
+ StateEventType.ROOM_NAME -> Result.success(false)
+ StateEventType.ROOM_AVATAR -> Result.failure(Throwable("Oops"))
+ StateEventType.ROOM_TOPIC -> Result.success(true)
+ else -> lambdaError()
+ }
+ }
+ )
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -197,6 +219,7 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
+ canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -240,6 +263,7 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
+ canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createRoomDetailsEditPresenter(room)
@@ -262,6 +286,7 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
+ canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter()
@@ -298,6 +323,7 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
+ canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(roomAvatarUri)
val presenter = createRoomDetailsEditPresenter(room)
@@ -346,6 +372,7 @@ class RoomDetailsEditPresenterTest {
displayName = "fallback",
avatarUrl = null,
emitRoomInfo = true,
+ canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(roomAvatarUri)
val presenter = createRoomDetailsEditPresenter(room)
@@ -389,11 +416,18 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - save changes room details if different`() = runTest {
+ val setNameResult = lambdaRecorder { _: String -> Result.success(Unit) }
+ val setTopicResult = lambdaRecorder { _: String -> Result.success(Unit) }
+ val removeAvatarResult = lambdaRecorder> { Result.success(Unit) }
val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
+ setNameResult = setNameResult,
+ setTopicResult = setTopicResult,
+ removeAvatarResult = removeAvatarResult,
+ canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -405,16 +439,20 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(5)
- assertThat(room.newName).isEqualTo("New name")
- assertThat(room.newTopic).isEqualTo("New topic")
- assertThat(room.newAvatarData).isNull()
- assertThat(room.removedAvatar).isTrue()
+ setNameResult.assertions().isCalledOnce().with(value("New name"))
+ setTopicResult.assertions().isCalledOnce().with(value("New topic"))
+ removeAvatarResult.assertions().isCalledOnce()
}
}
@Test
fun `present - save doesn't change room details if they're the same trimmed`() = runTest {
- val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(
+ topic = "My topic",
+ displayName = "Name",
+ avatarUrl = AN_AVATAR_URL,
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -423,17 +461,18 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
initialState.eventSink(RoomDetailsEditEvents.Save)
- assertThat(room.newName).isNull()
- assertThat(room.newTopic).isNull()
- assertThat(room.newAvatarData).isNull()
- assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save doesn't change topic if it was unset and is now blank`() = runTest {
- val room = aMatrixRoom(topic = null, displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(
+ topic = null,
+ displayName = "Name",
+ avatarUrl = AN_AVATAR_URL,
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -441,17 +480,18 @@ class RoomDetailsEditPresenterTest {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
initialState.eventSink(RoomDetailsEditEvents.Save)
- assertThat(room.newName).isNull()
- assertThat(room.newTopic).isNull()
- assertThat(room.newAvatarData).isNull()
- assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save doesn't change name if it's now empty`() = runTest {
- val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(
+ topic = "My topic",
+ displayName = "Name",
+ avatarUrl = AN_AVATAR_URL,
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -459,17 +499,20 @@ class RoomDetailsEditPresenterTest {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
initialState.eventSink(RoomDetailsEditEvents.Save)
- assertThat(room.newName).isNull()
- assertThat(room.newTopic).isNull()
- assertThat(room.newAvatarData).isNull()
- assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
- val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val updateAvatarResult = lambdaRecorder { _: String, _: ByteArray -> Result.success(Unit) }
+ val room = aMatrixRoom(
+ topic = "My topic",
+ displayName = "Name",
+ avatarUrl = AN_AVATAR_URL,
+ updateAvatarResult = updateAvatarResult,
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
givenPickerReturnsFile()
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -478,17 +521,19 @@ class RoomDetailsEditPresenterTest {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
- skipItems(3)
- assertThat(room.newName).isNull()
- assertThat(room.newTopic).isNull()
- assertThat(room.newAvatarData).isSameInstanceAs(fakeFileContents)
- assertThat(room.removedAvatar).isFalse()
+ skipItems(4)
+ updateAvatarResult.assertions().isCalledOnce().with(value("image/jpeg"), value(fakeFileContents))
}
}
@Test
fun `present - save does not set avatar data if processor fails`() = runTest {
- val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
+ val room = aMatrixRoom(
+ topic = "My topic",
+ displayName = "Name",
+ avatarUrl = AN_AVATAR_URL,
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
val presenter = createRoomDetailsEditPresenter(room)
@@ -498,11 +543,7 @@ class RoomDetailsEditPresenterTest {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
- skipItems(2)
- assertThat(room.newName).isNull()
- assertThat(room.newTopic).isNull()
- assertThat(room.newAvatarData).isNull()
- assertThat(room.removedAvatar).isFalse()
+ skipItems(3)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
@@ -514,9 +555,9 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
- ).apply {
- givenSetNameResult(Result.failure(Throwable("!")))
- }
+ setNameResult = { Result.failure(Throwable("!")) },
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name"))
}
@@ -527,9 +568,9 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
- ).apply {
- givenSetTopicResult(Result.failure(Throwable("!")))
- }
+ setTopicResult = { Result.failure(Throwable("!")) },
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
}
@@ -540,9 +581,9 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
- ).apply {
- givenRemoveAvatarResult(Result.failure(Throwable("!")))
- }
+ removeAvatarResult = { Result.failure(Throwable("!")) },
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
}
@@ -554,18 +595,22 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
- ).apply {
- givenUpdateAvatarResult(Result.failure(Throwable("!")))
- }
+ updateAvatarResult = { _, _ -> Result.failure(Throwable("!")) },
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
}
@Test
fun `present - CancelSaveChanges resets save action state`() = runTest {
givenPickerReturnsFile()
- val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
- givenSetTopicResult(Result.failure(Throwable("!")))
- }
+ val room = aMatrixRoom(
+ topic = "My topic",
+ displayName = "Name",
+ avatarUrl = AN_AVATAR_URL,
+ setTopicResult = { Result.failure(Throwable("!")) },
+ canSendStateResult = { _, _ -> Result.success(true) }
+ )
val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -573,7 +618,7 @@ class RoomDetailsEditPresenterTest {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
initialState.eventSink(RoomDetailsEditEvents.Save)
- skipItems(2)
+ skipItems(3)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt
index c6862f3e2e..8ca8b7b810 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt
@@ -52,7 +52,10 @@ class RoomMemberListPresenterTest {
@Test
fun `member loading is done automatically on start, but is async`() = runTest {
- val room = FakeMatrixRoom().apply {
+ val room = FakeMatrixRoom(
+ updateMembersResult = { Result.success(Unit) },
+ canInviteResult = { Result.success(true) }
+ ).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
}
@@ -62,7 +65,7 @@ class RoomMemberListPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
- assertThat(initialState.roomMembers.isLoading).isTrue()
+ assertThat(initialState.roomMembers.isLoading()).isTrue()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
@@ -70,15 +73,20 @@ class RoomMemberListPresenterTest {
// Skip item while the new members state is processed
skipItems(1)
val loadedMembersState = awaitItem()
- assertThat(loadedMembersState.roomMembers.isLoading).isFalse()
- assertThat(loadedMembersState.roomMembers.invited).isEqualTo(listOf(aVictor(), aWalter()))
- assertThat(loadedMembersState.roomMembers.joined).isNotEmpty()
+ assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
+ assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited).isEqualTo(listOf(aVictor(), aWalter()))
+ assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
}
}
@Test
fun `open search`() = runTest {
- val presenter = createPresenter()
+ val presenter = createPresenter(
+ matrixRoom = FakeMatrixRoom(
+ updateMembersResult = { Result.success(Unit) },
+ canInviteResult = { Result.success(true) }
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -93,7 +101,12 @@ class RoomMemberListPresenterTest {
@Test
fun `search for something which is not found`() = runTest {
- val presenter = createPresenter()
+ val presenter = createPresenter(
+ matrixRoom = FakeMatrixRoom(
+ updateMembersResult = { Result.success(Unit) },
+ canInviteResult = { Result.success(true) }
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -112,7 +125,12 @@ class RoomMemberListPresenterTest {
@Test
fun `search for something which is found`() = runTest {
- val presenter = createPresenter()
+ val presenter = createPresenter(
+ matrixRoom = FakeMatrixRoom(
+ updateMembersResult = { Result.success(Unit) },
+ canInviteResult = { Result.success(true) }
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -126,7 +144,7 @@ class RoomMemberListPresenterTest {
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice")
val searchSearchResultDelivered = awaitItem()
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
- assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.joined.first().displayName)
+ assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().displayName)
.isEqualTo("Alice")
}
}
@@ -134,9 +152,10 @@ class RoomMemberListPresenterTest {
@Test
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
val presenter = createPresenter(
- matrixRoom = FakeMatrixRoom().apply {
- givenCanInviteResult(Result.success(true))
- }
+ matrixRoom = FakeMatrixRoom(
+ canInviteResult = { Result.success(true) },
+ updateMembersResult = { Result.success(Unit) }
+ )
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -150,9 +169,10 @@ class RoomMemberListPresenterTest {
@Test
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
val presenter = createPresenter(
- matrixRoom = FakeMatrixRoom().apply {
- givenCanInviteResult(Result.success(false))
- }
+ matrixRoom = FakeMatrixRoom(
+ canInviteResult = { Result.success(false) },
+ updateMembersResult = { Result.success(Unit) }
+ )
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -166,9 +186,10 @@ class RoomMemberListPresenterTest {
@Test
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
val presenter = createPresenter(
- matrixRoom = FakeMatrixRoom().apply {
- givenCanInviteResult(Result.failure(Throwable("Eek")))
- }
+ matrixRoom = FakeMatrixRoom(
+ canInviteResult = { Result.failure(Throwable("Eek")) },
+ updateMembersResult = { Result.success(Unit) }
+ )
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -183,7 +204,14 @@ class RoomMemberListPresenterTest {
fun `present - RoomMemberSelected by default opens the room member details through the navigator`() = runTest {
val navigator = FakeRoomMemberListNavigator()
val moderationPresenter = FakeRoomMembersModerationPresenter(canDisplayModerationActions = false)
- val presenter = createPresenter(moderationPresenter = moderationPresenter, navigator = navigator)
+ val presenter = createPresenter(
+ moderationPresenter = moderationPresenter,
+ navigator = navigator,
+ matrixRoom = FakeMatrixRoom(
+ updateMembersResult = { Result.success(Unit) },
+ canInviteResult = { Result.success(true) }
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -205,7 +233,14 @@ class RoomMemberListPresenterTest {
val moderationPresenter = FakeRoomMembersModerationPresenter(canDisplayModerationActions = true).apply {
givenState(capturingState)
}
- val presenter = createPresenter(moderationPresenter = moderationPresenter, navigator = navigator)
+ val presenter = createPresenter(
+ moderationPresenter = moderationPresenter,
+ navigator = navigator,
+ matrixRoom = FakeMatrixRoom(
+ updateMembersResult = { Result.success(Unit) },
+ canInviteResult = { Result.success(true) }
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -236,10 +271,12 @@ private fun TestScope.createDataSource(
@ExperimentalCoroutinesApi
private fun TestScope.createPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
- matrixRoom: MatrixRoom = FakeMatrixRoom(),
+ matrixRoom: MatrixRoom = FakeMatrixRoom(
+ updateMembersResult = { Result.success(Unit) }
+ ),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
moderationPresenter: FakeRoomMembersModerationPresenter = FakeRoomMembersModerationPresenter(),
- navigator: RoomMemberListNavigator = object : RoomMemberListNavigator { }
+ navigator: RoomMemberListNavigator = object : RoomMemberListNavigator {}
) = RoomMemberListPresenter(
room = matrixRoom,
roomMemberListDataSource = roomMemberListDataSource,
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt
index c9df8d381c..1012de62bd 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt
@@ -34,6 +34,7 @@ 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -53,9 +54,11 @@ class RoomMemberDetailsPresenterTest {
@Test
fun `present - returns the room member's data, then updates it if needed`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
- val room = aMatrixRoom().apply {
- givenUserDisplayNameResult(Result.success("A custom name"))
- givenUserAvatarUrlResult(Result.success("A custom avatar"))
+ val room = aMatrixRoom(
+ userDisplayNameResult = { Result.success("A custom name") },
+ userAvatarUrlResult = { Result.success("A custom avatar") },
+ getUpdatedMemberResult = { Result.success(roomMember) },
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
}
val presenter = createRoomMemberDetailsPresenter(
@@ -82,11 +85,14 @@ class RoomMemberDetailsPresenterTest {
@Test
fun `present - will recover when retrieving room member details fails`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
- val room = aMatrixRoom().apply {
- givenUserDisplayNameResult(Result.failure(Throwable()))
- givenUserAvatarUrlResult(Result.failure(Throwable()))
+ val room = aMatrixRoom(
+ userDisplayNameResult = { Result.failure(Throwable()) },
+ userAvatarUrlResult = { Result.failure(Throwable()) },
+ getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
}
+
val presenter = createRoomMemberDetailsPresenter(
room = room,
roomMemberId = roomMember.userId
@@ -105,9 +111,11 @@ class RoomMemberDetailsPresenterTest {
@Test
fun `present - will fallback to original data if the updated data is null`() = runTest {
val roomMember = aRoomMember(displayName = "Alice")
- val room = aMatrixRoom().apply {
- givenUserDisplayNameResult(Result.success(null))
- givenUserAvatarUrlResult(Result.success(null))
+ val room = aMatrixRoom(
+ userDisplayNameResult = { Result.success(null) },
+ userAvatarUrlResult = { Result.success(null) },
+ getUpdatedMemberResult = { Result.success(roomMember) }
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(roomMember)))
}
val presenter = createRoomMemberDetailsPresenter(
@@ -128,10 +136,11 @@ class RoomMemberDetailsPresenterTest {
@Test
fun `present - will fallback to user profile if user is not a member of the room`() = runTest {
val bobProfile = aMatrixUser("@bob:server.org", "Bob", avatarUrl = "anAvatarUrl")
- val room = aMatrixRoom().apply {
- givenUserDisplayNameResult(Result.failure(Exception("Not a member!")))
- givenUserAvatarUrlResult(Result.failure(Exception("Not a member!")))
- }
+ val room = aMatrixRoom(
+ userDisplayNameResult = { Result.failure(Exception("Not a member!")) },
+ userAvatarUrlResult = { Result.failure(Exception("Not a member!")) },
+ getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
+ )
val client = FakeMatrixClient().apply {
givenGetProfileResult(bobProfile.userId, Result.success(bobProfile))
}
@@ -154,7 +163,13 @@ class RoomMemberDetailsPresenterTest {
@Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
- val presenter = createRoomMemberDetailsPresenter()
+ val presenter = createRoomMemberDetailsPresenter(
+ room = aMatrixRoom(
+ getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
+ userDisplayNameResult = { Result.success("Alice") },
+ userAvatarUrlResult = { Result.success("anAvatarUrl") },
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -176,6 +191,11 @@ class RoomMemberDetailsPresenterTest {
val client = FakeMatrixClient()
val roomMember = aRoomMember()
val presenter = createRoomMemberDetailsPresenter(
+ room = aMatrixRoom(
+ getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
+ userDisplayNameResult = { Result.success("Alice") },
+ userAvatarUrlResult = { Result.success("anAvatarUrl") },
+ ),
client = client,
roomMemberId = roomMember.userId
)
@@ -199,13 +219,21 @@ class RoomMemberDetailsPresenterTest {
fun `present - BlockUser with error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE))
- val presenter = createRoomMemberDetailsPresenter(client = matrixClient)
+ val presenter = createRoomMemberDetailsPresenter(
+ client = matrixClient,
+ room = aMatrixRoom(
+ getUpdatedMemberResult = { Result.success(aRoomMember(displayName = "Alice")) },
+ userDisplayNameResult = { Result.success("Alice") },
+ userAvatarUrlResult = { Result.success("anAvatarUrl") },
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ skipItems(2)
val errorState = awaitItem()
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
// Clear error
@@ -218,13 +246,21 @@ class RoomMemberDetailsPresenterTest {
fun `present - UnblockUser with error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE))
- val presenter = createRoomMemberDetailsPresenter(client = matrixClient)
+ val presenter = createRoomMemberDetailsPresenter(
+ room = aMatrixRoom(
+ getUpdatedMemberResult = { Result.success(aRoomMember(displayName = "Alice")) },
+ userDisplayNameResult = { Result.success("Alice") },
+ userAvatarUrlResult = { Result.success("anAvatarUrl") },
+ ),
+ client = matrixClient,
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
+ skipItems(2)
val errorState = awaitItem()
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
// Clear error
@@ -235,7 +271,13 @@ class RoomMemberDetailsPresenterTest {
@Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
- val presenter = createRoomMemberDetailsPresenter()
+ val presenter = createRoomMemberDetailsPresenter(
+ room = aMatrixRoom(
+ getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) },
+ userDisplayNameResult = { Result.success("Alice") },
+ userAvatarUrlResult = { Result.success("anAvatarUrl") },
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -255,7 +297,14 @@ class RoomMemberDetailsPresenterTest {
@Test
fun `present - start DM action complete scenario`() = runTest {
val startDMAction = FakeStartDMAction()
- val presenter = createRoomMemberDetailsPresenter(startDMAction = startDMAction)
+ val presenter = createRoomMemberDetailsPresenter(
+ room = aMatrixRoom(
+ getUpdatedMemberResult = { Result.success(aRoomMember(displayName = "Alice")) },
+ userDisplayNameResult = { Result.success("Alice") },
+ userAvatarUrlResult = { Result.success("anAvatarUrl") },
+ ),
+ startDMAction = startDMAction,
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -268,6 +317,7 @@ class RoomMemberDetailsPresenterTest {
startDMAction.givenExecuteResult(startDMFailureResult)
initialState.eventSink(UserProfileEvents.StartDM)
assertThat(awaitItem().startDmActionState).isInstanceOf(AsyncAction.Loading::class.java)
+ skipItems(2)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(startDMFailureResult)
state.eventSink(UserProfileEvents.ClearStartDMState)
@@ -292,8 +342,8 @@ class RoomMemberDetailsPresenterTest {
}
private fun createRoomMemberDetailsPresenter(
+ room: MatrixRoom,
client: MatrixClient = FakeMatrixClient(),
- room: MatrixRoom = aMatrixRoom(),
roomMemberId: UserId = UserId("@alice:server.org"),
startDMAction: StartDMAction = FakeStartDMAction()
): RoomMemberDetailsPresenter {
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTest.kt
index 8909870669..5f704a23d4 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTest.kt
@@ -45,7 +45,7 @@ import org.junit.Test
class DefaultRoomMembersModerationPresenterTest {
@Test
fun `canDisplayModerationActions - when room is DM is false`() = runTest {
- val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply {
+ val room = FakeMatrixRoom(isDirect = true, isPublic = true, activeMemberCount = 2).apply {
givenRoomInfo(aRoomInfo(isDirect = true, isPublic = false, activeMembersCount = 2))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
@@ -54,29 +54,34 @@ class DefaultRoomMembersModerationPresenterTest {
@Test
fun `canDisplayModerationActions - when user can kick other users, FF is enabled and room is not a DM returns true`() = runTest {
- val room = FakeMatrixRoom(isDirect = false, isOneToOne = false).apply {
- givenCanKickResult(Result.success(true))
- }
+ val room = FakeMatrixRoom(
+ isDirect = false,
+ activeMemberCount = 10,
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ )
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isTrue()
}
@Test
fun `canDisplayModerationActions - when user can ban other users, FF is enabled and room is not a DM returns true`() = runTest {
- val room = FakeMatrixRoom(isDirect = false, isOneToOne = false).apply {
- givenCanBanResult(Result.success(true))
- }
+ val room = FakeMatrixRoom(
+ isDirect = false,
+ activeMemberCount = 10,
+ canBanResult = { Result.success(true) },
+ )
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isTrue()
}
@Test
fun `present - SelectRoomMember when the current user has permissions displays member actions`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
- givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
- }
+ val room = FakeMatrixRoom(
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
+ )
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -98,11 +103,12 @@ class DefaultRoomMembersModerationPresenterTest {
@Test
fun `present - SelectRoomMember displays only view profile if selected member has same power level as the current user`() = runTest {
- val room = FakeMatrixRoom(sessionId = A_USER_ID).apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
- givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
- }
+ val room = FakeMatrixRoom(
+ sessionId = A_USER_ID,
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
+ )
val selectedMember = aRoomMember(A_USER_ID_2, powerLevel = 100L)
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -123,11 +129,11 @@ class DefaultRoomMembersModerationPresenterTest {
@Test
fun `present - SelectRoomMember displays an unban confirmation dialog when the member is banned`() = runTest {
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
- val room = FakeMatrixRoom().apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
- givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
- }
+ val room = FakeMatrixRoom(
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
+ )
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -144,11 +150,12 @@ class DefaultRoomMembersModerationPresenterTest {
@Test
fun `present - Kick removes the user`() = runTest {
val analyticsService = FakeAnalyticsService()
- val room = FakeMatrixRoom().apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
- givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
- }
+ val room = FakeMatrixRoom(
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
+ kickUserResult = { _, _ -> Result.success(Unit) },
+ )
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
@@ -171,11 +178,12 @@ class DefaultRoomMembersModerationPresenterTest {
@Test
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
- val room = FakeMatrixRoom().apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
- givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
- }
+ val room = FakeMatrixRoom(
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
+ banUserResult = { _, _ -> Result.success(Unit) },
+ )
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
@@ -204,11 +212,13 @@ class DefaultRoomMembersModerationPresenterTest {
fun `present - UnbanUser requires confirmation and then unbans the user`() = runTest {
val analyticsService = FakeAnalyticsService()
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
- val room = FakeMatrixRoom().apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
+ val room = FakeMatrixRoom(
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ userRoleResult = { Result.success(RoomMember.Role.ADMIN) },
+ unBanUserResult = { _, _ -> Result.success(Unit) },
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(selectedMember)))
- givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
@@ -231,10 +241,11 @@ class DefaultRoomMembersModerationPresenterTest {
@Test
fun `present - Reset removes the selected user and actions`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
- }
+ val room = FakeMatrixRoom(
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ userRoleResult = { Result.success(RoomMember.Role.USER) },
+ )
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -251,13 +262,14 @@ class DefaultRoomMembersModerationPresenterTest {
@Test
fun `present - Reset resets any async actions`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenCanKickResult(Result.success(true))
- givenCanBanResult(Result.success(true))
- givenKickUserResult(Result.failure(Throwable("Eek")))
- givenBanUserResult(Result.failure(Throwable("Eek")))
- givenUnbanUserResult(Result.failure(Throwable("Eek")))
- }
+ val room = FakeMatrixRoom(
+ canKickResult = { Result.success(true) },
+ canBanResult = { Result.success(true) },
+ kickUserResult = { _, _ -> Result.failure(Throwable("Eek")) },
+ banUserResult = { _, _ -> Result.failure(Throwable("Eek")) },
+ unBanUserResult = { _, _ -> Result.failure(Throwable("Eek")) },
+ userRoleResult = { Result.success(RoomMember.Role.USER) },
+ )
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTest.kt
index 35353f0d1a..c2eb07002c 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTest.kt
@@ -27,6 +27,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevels
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -67,7 +68,12 @@ class RolesAndPermissionPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - DemoteSelfTo changes own role to the specified one`() = runTest(StandardTestDispatcher()) {
- val presenter = createRolesAndPermissionsPresenter(dispatchers = testCoroutineDispatchers())
+ val presenter = createRolesAndPermissionsPresenter(
+ dispatchers = testCoroutineDispatchers(),
+ room = FakeMatrixRoom(
+ updateUserRoleResult = { Result.success(Unit) }
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -85,9 +91,9 @@ class RolesAndPermissionPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - DemoteSelfTo can handle failures and clean them`() = runTest(StandardTestDispatcher()) {
- val room = FakeMatrixRoom().apply {
- givenUpdateUserRoleResult(Result.failure(Exception("Failed to update role")))
- }
+ val room = FakeMatrixRoom(
+ updateUserRoleResult = { Result.failure(Exception("Failed to update role")) }
+ )
val presenter = createRolesAndPermissionsPresenter(room = room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -123,7 +129,12 @@ class RolesAndPermissionPresenterTest {
@Test
fun `present - ResetPermissions needs confirmation, then resets permissions`() = runTest {
val analyticsService = FakeAnalyticsService()
- val presenter = createRolesAndPermissionsPresenter(analyticsService = analyticsService)
+ val presenter = createRolesAndPermissionsPresenter(
+ analyticsService = analyticsService,
+ room = FakeMatrixRoom(
+ resetPowerLevelsResult = { Result.success(defaultRoomPowerLevels()) }
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt
index d4155d9924..71223c8ff3 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt
@@ -275,7 +275,10 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Save will display a confirmation when adding admins`() = runTest {
- val room = FakeMatrixRoom().apply {
+ val room = FakeMatrixRoom(
+ updateUserRoleResult = { Result.success(Unit) },
+ updateMembersResult = { Result.success(Unit) },
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
}
@@ -325,7 +328,10 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Save will just save the data for moderators`() = runTest {
val analyticsService = FakeAnalyticsService()
- val room = FakeMatrixRoom().apply {
+ val room = FakeMatrixRoom(
+ updateUserRoleResult = { Result.success(Unit) },
+ updateMembersResult = { Result.success(Unit) },
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 50)))
}
@@ -351,10 +357,11 @@ class ChangeRolesPresenterTest {
@Test
fun `present - Save can handle failures and ClearError clears them`() = runTest {
- val room = FakeMatrixRoom().apply {
+ val room = FakeMatrixRoom(
+ updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) }
+ ).apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 50)))
- givenUpdateUserRoleResult(Result.failure(IllegalStateException("Failed")))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.MODERATOR, room = room)
moleculeFlow(RecompositionMode.Immediate) {
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTest.kt
index c37d458d67..5d2ae18783 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTest.kt
@@ -164,7 +164,13 @@ class ChangeRoomPermissionsPresenterTest {
@Test
fun `present - Save updates the current permissions and resets hasChanges`() = runTest {
val analyticsService = FakeAnalyticsService()
- val presenter = createChangeRoomPermissionsPresenter(analyticsService = analyticsService)
+ val presenter = createChangeRoomPermissionsPresenter(
+ analyticsService = analyticsService,
+ room = FakeMatrixRoom(
+ updatePowerLevelsResult = { Result.success(Unit) },
+ powerLevelsResult = { Result.success(defaultPermissions()) }
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -208,9 +214,9 @@ class ChangeRoomPermissionsPresenterTest {
@Test
fun `present - Save will fail if there are not current permissions`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenPowerLevelsResult(Result.failure(IllegalStateException("Failed to load power levels")))
- }
+ val room = FakeMatrixRoom(
+ powerLevelsResult = { Result.failure(IllegalStateException("Failed to load power levels")) }
+ )
val presenter = createChangeRoomPermissionsPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -225,9 +231,10 @@ class ChangeRoomPermissionsPresenterTest {
@Test
fun `present - Save can handle failures and they can be cleared`() = runTest {
- val room = FakeMatrixRoom().apply {
- givenUpdatePowerLevelsResult(Result.failure(IllegalStateException("Failed to update power levels")))
- }
+ val room = FakeMatrixRoom(
+ powerLevelsResult = { Result.success(defaultPermissions()) },
+ updatePowerLevelsResult = { Result.failure(IllegalStateException("Failed to update power levels")) },
+ )
val presenter = createChangeRoomPermissionsPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -292,7 +299,9 @@ class ChangeRoomPermissionsPresenterTest {
private fun createChangeRoomPermissionsPresenter(
section: ChangeRoomPermissionsSection = ChangeRoomPermissionsSection.RoomDetails,
- room: FakeMatrixRoom = FakeMatrixRoom(),
+ room: FakeMatrixRoom = FakeMatrixRoom(
+ powerLevelsResult = { Result.success(defaultPermissions()) }
+ ),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = ChangeRoomPermissionsPresenter(
section = section,
diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts
index dac4e1c507..60403b25b0 100644
--- a/features/roomlist/impl/build.gradle.kts
+++ b/features/roomlist/impl/build.gradle.kts
@@ -55,6 +55,7 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.push.api)
implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.features.leaveroom.api)
@@ -79,6 +80,7 @@ dependencies {
testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.features.networkmonitor.test)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
index 582c5e083b..01e370bc1a 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt
@@ -96,36 +96,35 @@ private fun RoomListModalBottomSheetContent(
}
)
if (contextMenu.markAsUnreadFeatureFlagEnabled) {
- ListItem(
- headlineContent = {
- Text(
- text = stringResource(
- id = if (contextMenu.hasNewContent) {
- R.string.screen_roomlist_mark_as_read
- } else {
- R.string.screen_roomlist_mark_as_unread
- }
- ),
- style = MaterialTheme.typography.bodyLarge,
- )
- },
- modifier = Modifier.clickable {
- if (contextMenu.hasNewContent) {
- onRoomMarkReadClick()
- } else {
- onRoomMarkUnreadClick()
- }
- },
- /* TODO Design
- leadingContent = ListItemContent.Icon(
- iconSource = IconSource.Vector(
- CompoundIcons.Settings,
- contentDescription = stringResource(id = CommonStrings.common_settings)
- )
- ),
- */
- style = ListItemStyle.Primary,
- )
+ if (contextMenu.hasNewContent) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(id = R.string.screen_roomlist_mark_as_read),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ onClick = onRoomMarkReadClick,
+ leadingContent = ListItemContent.Icon(
+ iconSource = IconSource.Vector(CompoundIcons.MarkAsRead())
+ ),
+ style = ListItemStyle.Primary,
+ )
+ } else {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ onClick = onRoomMarkUnreadClick,
+ leadingContent = ListItemContent.Icon(
+ iconSource = IconSource.Vector(CompoundIcons.MarkAsUnread())
+ ),
+ style = ListItemStyle.Primary,
+ )
+ }
}
ListItem(
headlineContent = {
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
index e85dcac2ee..d5f9404eff 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
@@ -63,11 +63,14 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
+import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -95,6 +98,7 @@ class RoomListPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter,
private val fullScreenIntentPermissionsPresenter: FullScreenIntentPermissionsPresenter,
+ private val notificationCleaner: NotificationCleaner,
) : Presenter {
private val encryptionService: EncryptionService = client.encryptionService()
private val syncService: SyncService = client.syncService()
@@ -124,7 +128,9 @@ class RoomListPresenter @Inject constructor(
fun handleEvents(event: RoomListEvents) {
when (event) {
- is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
+ is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
+ updateVisibleRange(event.range)
+ }
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvents.DismissRecoveryKeyPrompt -> securityBannerDismissed = true
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
@@ -264,6 +270,7 @@ class RoomListPresenter @Inject constructor(
}
private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
+ notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
client.getRoom(roomId)?.use { room ->
room.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
@@ -287,14 +294,20 @@ class RoomListPresenter @Inject constructor(
}
}
- private fun updateVisibleRange(range: IntRange) {
- if (range.isEmpty()) return
- val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
- val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0)
- // Safe to give bigger size than room list
- val extendedRangeEnd = range.last + midExtendedRangeSize
- val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd)
- client.roomListService.updateAllRoomsVisibleRange(extendedRange)
+ private var currentUpdateVisibleRangeJob: Job? = null
+ private fun CoroutineScope.updateVisibleRange(range: IntRange) {
+ currentUpdateVisibleRangeJob?.cancel()
+ currentUpdateVisibleRangeJob = launch(SupervisorJob()) {
+ if (range.isEmpty()) return@launch
+ val currentRoomList = roomListDataSource.allRooms.first()
+ // Use extended range to 'prefetch' the next rooms info
+ val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
+ val extendedRange = range.first until range.last + midExtendedRangeSize
+ val roomIds = extendedRange.mapNotNull { index ->
+ currentRoomList.getOrNull(index)?.roomId
+ }
+ roomListDataSource.subscribeToVisibleRooms(roomIds)
+ }
}
}
@@ -303,5 +316,5 @@ internal fun RoomListRoomSummary.toInviteData() = InviteData(
roomId = roomId,
// Note: `name` should not be null at this point, but just in case, fallback to the roomId
roomName = name ?: roomId.value,
- isDirect = isDirect,
+ isDm = isDm,
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
index 377606cacf..5bc85d3458 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
@@ -30,17 +30,16 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
-import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -179,17 +178,13 @@ private fun RoomsViewList(
firstItemIndex until firstItemIndex + size
}
}
- val nestedScrollConnection = remember {
- object : NestedScrollConnection {
- override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
- eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
- return super.onPostFling(consumed, available)
- }
- }
+ val updatedEventSink by rememberUpdatedState(newValue = eventSink)
+ LaunchedEffect(visibleRange) {
+ updatedEventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
}
LazyColumn(
state = lazyListState,
- modifier = modifier.nestedScroll(nestedScrollConnection),
+ modifier = modifier,
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
contentPadding = PaddingValues(bottom = 80.dp)
) {
@@ -199,7 +194,7 @@ private fun RoomsViewList(
item {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
- onDismissClick = { eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
+ onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
index f7602faa16..d80f1c7334 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt
@@ -95,8 +95,8 @@ internal fun RoomSummaryRow(
modifier = modifier
) {
InviteNameAndIndicatorRow(name = room.name)
- InviteSubtitle(isDirect = room.isDirect, inviteSender = room.inviteSender, canonicalAlias = room.canonicalAlias)
- if (!room.isDirect && room.inviteSender != null) {
+ InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender, canonicalAlias = room.canonicalAlias)
+ if (!room.isDm && room.inviteSender != null) {
Spacer(modifier = Modifier.height(4.dp))
InviteSenderView(
modifier = Modifier.fillMaxWidth(),
@@ -206,12 +206,12 @@ private fun NameAndTimestampRow(
@Composable
private fun InviteSubtitle(
- isDirect: Boolean,
+ isDm: Boolean,
inviteSender: InviteSender?,
canonicalAlias: RoomAlias?,
modifier: Modifier = Modifier
) {
- val subtitle = if (isDirect) {
+ val subtitle = if (isDm) {
inviteSender?.userId?.value
} else {
canonicalAlias?.value
@@ -307,19 +307,19 @@ private fun InviteButtonsRow(
modifier: Modifier = Modifier
) {
Row(
- modifier = modifier.padding(),
+ modifier = modifier,
horizontalArrangement = spacedBy(12.dp)
) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineClick,
- size = ButtonSize.Medium,
+ size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptClick,
- size = ButtonSize.Medium,
+ size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
index e2508446e7..6e7f386b14 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt
@@ -20,6 +20,7 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@@ -54,9 +55,13 @@ class RoomListDataSource @Inject constructor(
private val lock = Mutex()
private val diffCache = MutableListDiffCache()
private val diffCacheUpdater = DiffCacheUpdater(diffCache = diffCache, detectMoves = true) { old, new ->
- old?.identifier() == new?.identifier()
+ old?.roomId == new?.roomId
}
+ val allRooms: Flow> = _allRooms
+
+ val loadingState = roomListService.allRooms.loadingState
+
fun launchIn(coroutineScope: CoroutineScope) {
roomListService
.allRooms
@@ -67,9 +72,9 @@ class RoomListDataSource @Inject constructor(
.launchIn(coroutineScope)
}
- val allRooms: Flow> = _allRooms
-
- val loadingState = roomListService.allRooms.loadingState
+ suspend fun subscribeToVisibleRooms(roomIds: List) {
+ roomListService.subscribeToVisibleRooms(roomIds)
+ }
@OptIn(FlowPreview::class)
private fun observeNotificationSettings() {
@@ -96,12 +101,8 @@ class RoomListDataSource @Inject constructor(
}
private fun buildAndCacheItem(roomSummaries: List, index: Int): RoomListRoomSummary? {
- val roomListRoomSummary = when (val roomSummary = roomSummaries.getOrNull(index)) {
- is RoomSummary.Empty -> RoomListRoomSummaryFactory.createPlaceholder(roomSummary.identifier)
- is RoomSummary.Filled -> roomListRoomSummaryFactory.create(roomSummary)
- null -> null
- }
- diffCache[index] = roomListRoomSummary
- return roomListRoomSummary
+ val roomListSummary = roomSummaries.getOrNull(index)?.let { roomListRoomSummaryFactory.create(it) }
+ diffCache[index] = roomListSummary
+ return roomListSummary
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
index 18ba73bdc1..153c7cf41d 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListRoomSummaryFactory.kt
@@ -20,16 +20,12 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
-import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
-import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
-import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender
-import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
@@ -37,37 +33,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter,
) {
- companion object {
- fun createPlaceholder(id: String): RoomListRoomSummary {
- return RoomListRoomSummary(
- id = id,
- roomId = RoomId(id),
- displayType = RoomSummaryDisplayType.PLACEHOLDER,
- name = "Short name",
- timestamp = "hh:mm",
- lastMessage = "Last message for placeholder",
- avatarData = AvatarData(id, "S", size = AvatarSize.RoomListItem),
- numberOfUnreadMessages = 0,
- numberOfUnreadMentions = 0,
- numberOfUnreadNotifications = 0,
- isMarkedUnread = false,
- userDefinedNotificationMode = null,
- hasRoomCall = false,
- isDirect = false,
- isFavorite = false,
- inviteSender = null,
- isDm = false,
- canonicalAlias = null,
- heroes = persistentListOf(),
- )
- }
- }
-
- fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {
- return create(roomSummary.details)
- }
-
- private fun create(details: RoomSummaryDetails): RoomListRoomSummary {
+ fun create(details: RoomSummary): RoomListRoomSummary {
val avatarData = details.getAvatarData(size = AvatarSize.RoomListItem)
return RoomListRoomSummary(
id = details.roomId.value,
@@ -79,7 +45,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
isMarkedUnread = details.isMarkedUnread,
timestamp = lastMessageTimestampFormatter.format(details.lastMessageTimestamp),
lastMessage = details.lastMessage?.let { message ->
- roomLastMessageFormatter.format(message.event, details.isDirect)
+ roomLastMessageFormatter.format(message.event, details.isDm)
}.orEmpty(),
avatarData = avatarData,
userDefinedNotificationMode = details.userDefinedNotificationMode,
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
index 4292492af5..e67f7b2faf 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenter.kt
@@ -17,13 +17,13 @@
package io.element.android.features.roomlist.impl.filters
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.flow.map
import javax.inject.Inject
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@@ -31,10 +31,10 @@ class RoomListFiltersPresenter @Inject constructor(
private val roomListService: RoomListService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter {
+ private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toPersistentList()
+
@Composable
override fun present(): RoomListFiltersState {
- val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
-
fun handleEvents(event: RoomListFiltersEvents) {
when (event) {
RoomListFiltersEvents.ClearSelectedFilters -> {
@@ -46,12 +46,15 @@ class RoomListFiltersPresenter @Inject constructor(
}
}
- LaunchedEffect(filters) {
- val allRoomsFilter = MatrixRoomListFilter.All(
- filters
- .filter { it.isSelected }
- .map { roomListFilter ->
- when (roomListFilter.filter) {
+ val filters by produceState(initialValue = initialFilters) {
+ filterSelectionStrategy.filterSelectionStates
+ .map { filters ->
+ value = filters.toPersistentList()
+ filters.mapNotNull { filterState ->
+ if (!filterState.isSelected) {
+ return@mapNotNull null
+ }
+ when (filterState.filter) {
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
@@ -59,12 +62,15 @@ class RoomListFiltersPresenter @Inject constructor(
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
}
}
- )
- roomListService.allRooms.updateFilter(allRoomsFilter)
+ }
+ .collect { filters ->
+ val result = MatrixRoomListFilter.All(filters)
+ roomListService.allRooms.updateFilter(result)
+ }
}
return RoomListFiltersState(
- filterSelectionStates = filters.toPersistentList(),
+ filterSelectionStates = filters,
eventSink = ::handleEvents
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
index 473c60049a..cf44be6090 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt
@@ -101,7 +101,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider> = roomList.filteredSummaries
.map { roomSummaries ->
roomSummaries
- .filterIsInstance()
.map(roomSummaryFactory::create)
.toPersistentList()
}
diff --git a/features/roomlist/impl/src/main/res/values-el/translations.xml b/features/roomlist/impl/src/main/res/values-el/translations.xml
index b3a4a8f04d..71aca711ad 100644
--- a/features/roomlist/impl/src/main/res/values-el/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-el/translations.xml
@@ -25,12 +25,13 @@
"Μπορείς να καταργήσεις την επιλογή φίλτρων για να δεις τις άλλες συνομιλίες σου"
"Δεν έχεις συνομιλίες για αυτήν την επιλογή"
"Άτομα"
+ "Δεν έχεις ακόμα ΠΜ"
"Δωμάτια"
"Δεν είσαι ακόμα σε κανένα δωμάτιο"
"Μη αναγνωσμένα"
"Συγχαρητήρια!
Δεν έχεις μη αναγνωσμένα μηνύματα!"
- "Συζητήσεις"
+ "Συνομιλίες"
"Επισήμανση ως αναγνωσμένου"
"Επισήμανση ως μη αναγνωσμένου"
"Περιήγηση σε όλα τα δωμάτια"
diff --git a/features/roomlist/impl/src/main/res/values-ka/translations.xml b/features/roomlist/impl/src/main/res/values-ka/translations.xml
index de6358f443..dac714cd0a 100644
--- a/features/roomlist/impl/src/main/res/values-ka/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ka/translations.xml
@@ -1,5 +1,7 @@
+ "თქვენი ჩეთების სარეზერვო ასლი ამჟამად არ არის სინქრონიზებული. თქვენ უნდა შეიყვანოთ თქვენი აღდგენის გასაღები, რათა შეინარჩუნოთ წვდომა ჩეთების სარეზერვო ასლზე."
+ "შეიყვანეთ აღდგენის გასაღები"
"დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?"
"მოწვევაზე უარის თქმა"
"დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?"
diff --git a/features/roomlist/impl/src/main/res/values-pl/translations.xml b/features/roomlist/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..0ae62297a1
--- /dev/null
+++ b/features/roomlist/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,22 @@
+
+
+ "Twoja kopia zapasowa czatu jest obecnie niezsynchronizowana. Aby zachować dostęp do kopii zapasowej czatu, musisz potwierdzić klucz odzyskiwania."
+ "Potwierdź klucz odzyskiwania"
+ "Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu."
+ "Popraw jakość swoich rozmów"
+ "Czy na pewno chcesz odrzucić zaproszenie do dołączenia do %1$s?"
+ "Odrzuć zaproszenie"
+ "Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"
+ "Odrzuć czat"
+ "Brak zaproszeń"
+ "%1$s (%2$s) zaprosił Cię"
+ "Jest to jednorazowy proces, dziękujemy za czekanie."
+ "Konfigurowanie Twojego konta."
+ "Utwórz nową rozmowę lub pokój"
+ "Wyślij komuś wiadomość, aby rozpocząć."
+ "Brak czatów."
+ "Osoby"
+ "Wszystkie czaty"
+ "Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości."
+ "Potwierdź, że to Ty"
+
diff --git a/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..6765199a70
--- /dev/null
+++ b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,19 @@
+
+
+ "Insira sua chave de recuperação"
+ "Tem certeza de que deseja recusar o convite para ingressar em %1$s?"
+ "Recusar convite"
+ "Tem certeza de que deseja recusar esse chat privado com %1$s?"
+ "Recusar chat"
+ "Sem convites"
+ "%1$s(%2$s) convidou você"
+ "Este é um processo único, obrigado por esperar."
+ "Configurando sua conta."
+ "Criar uma nova conversa ou sala"
+ "Comece enviando uma mensagem para alguém."
+ "Ainda não há conversas."
+ "Pessoas"
+ "Conversas"
+ "Parece que você está usando um novo dispositivo. Verifique com outro dispositivo para acessar suas mensagens criptografadas."
+ "Verifique se é você"
+
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
index 919eb87045..7a9a8b2744 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
@@ -50,6 +50,8 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.test.FakeFullScreenIntentPermissionsPresenter
import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
@@ -61,6 +63,9 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_ID_3
+import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -68,12 +73,14 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
-import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
+import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
+import io.element.android.libraries.push.api.notifications.NotificationCleaner
+import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
@@ -183,7 +190,7 @@ class RoomListPresenterTest {
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(
listOf(
- aRoomSummaryFilled(
+ aRoomSummary(
numUnreadMentions = 1,
numUnreadMessages = 2,
)
@@ -203,48 +210,6 @@ class RoomListPresenterTest {
}
}
- @Test
- fun `present - update visible range`() = runTest {
- val roomListService = FakeRoomListService()
- val matrixClient = FakeMatrixClient(
- roomListService = roomListService
- )
- val scope = CoroutineScope(coroutineContext + SupervisorJob())
- val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- roomListService.postAllRooms(listOf(aRoomSummaryFilled()))
- val loadedState = awaitItem()
- // check initial value
- assertThat(roomListService.latestSlidingSyncRange).isNull()
- // Test empty range
- loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(1, 0)))
- assertThat(roomListService.latestSlidingSyncRange).isNull()
- // Update visible range and check that range is transmitted to the SDK after computation
- loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 0)))
- assertThat(roomListService.latestSlidingSyncRange)
- .isEqualTo(IntRange(0, 20))
- loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 1)))
- assertThat(roomListService.latestSlidingSyncRange)
- .isEqualTo(IntRange(0, 21))
- loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(19, 29)))
- assertThat(roomListService.latestSlidingSyncRange)
- .isEqualTo(IntRange(0, 49))
- loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(49, 59)))
- assertThat(roomListService.latestSlidingSyncRange)
- .isEqualTo(IntRange(29, 79))
- loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 159)))
- assertThat(roomListService.latestSlidingSyncRange)
- .isEqualTo(IntRange(129, 179))
- loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 259)))
- assertThat(roomListService.latestSlidingSyncRange)
- .isEqualTo(IntRange(129, 279))
- cancelAndIgnoreRemainingEvents()
- scope.cancel()
- }
- }
-
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
@@ -449,7 +414,7 @@ class RoomListPresenterTest {
val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- roomListService.postAllRooms(listOf(aRoomSummaryFilled(notificationMode = userDefinedMode)))
+ roomListService.postAllRooms(listOf(aRoomSummary(notificationMode = userDefinedMode)))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
notificationSettingsService = notificationSettingsService
@@ -476,7 +441,10 @@ class RoomListPresenterTest {
@Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
- val room = FakeMatrixRoom()
+ val setIsFavoriteResult = lambdaRecorder { _: Boolean -> Result.success(Unit) }
+ val room = FakeMatrixRoom(
+ setIsFavoriteResult = setIsFavoriteResult
+ )
val analyticsService = FakeAnalyticsService()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
@@ -487,9 +455,13 @@ class RoomListPresenterTest {
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, true))
- assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true))
+ setIsFavoriteResult.assertions().isCalledOnce().with(value(true))
initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, false))
- assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true, false))
+ setIsFavoriteResult.assertions().isCalledExactly(2)
+ .withSequence(
+ listOf(value(true)),
+ listOf(value(false)),
+ )
assertThat(analyticsService.capturedEvents).containsExactly(
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle),
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle)
@@ -544,35 +516,54 @@ class RoomListPresenterTest {
@Test
fun `present - check that the room is marked as read with correct RR and as unread`() = runTest {
val room = FakeMatrixRoom()
+ val room2 = FakeMatrixRoom(roomId = A_ROOM_ID_2)
+ val room3 = FakeMatrixRoom(roomId = A_ROOM_ID_3)
+ val allRooms = setOf(room, room2, room3)
val sessionPreferencesStore = InMemorySessionPreferencesStore()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
+ givenGetRoomResult(A_ROOM_ID_2, room2)
+ givenGetRoomResult(A_ROOM_ID_3, room3)
}
val analyticsService = FakeAnalyticsService()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val clearMessagesForRoomLambda = lambdaRecorder { _, _ -> }
+ val notificationCleaner = FakeNotificationCleaner(
+ clearMessagesForRoomLambda = clearMessagesForRoomLambda,
+ )
val presenter = createRoomListPresenter(
client = matrixClient,
coroutineScope = scope,
sessionPreferencesStore = sessionPreferencesStore,
analyticsService = analyticsService,
+ notificationCleaner = notificationCleaner,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(room.markAsReadCalls).isEmpty()
- assertThat(room.setUnreadFlagCalls).isEmpty()
+ allRooms.forEach {
+ assertThat(it.markAsReadCalls).isEmpty()
+ assertThat(it.setUnreadFlagCalls).isEmpty()
+ }
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ))
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
- initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID))
- assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ))
- assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false, true))
+ clearMessagesForRoomLambda.assertions().isCalledOnce()
+ .with(value(A_SESSION_ID), value(A_ROOM_ID))
+ initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID_2))
+ assertThat(room2.markAsReadCalls).isEmpty()
+ assertThat(room2.setUnreadFlagCalls).isEqualTo(listOf(true))
// Test again with private read receipts
sessionPreferencesStore.setSendPublicReadReceipts(false)
- initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
- assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ, ReceiptType.READ_PRIVATE))
- assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false, true, false))
+ initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID_3))
+ assertThat(room3.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ_PRIVATE))
+ assertThat(room3.setUnreadFlagCalls).isEqualTo(listOf(false))
+ clearMessagesForRoomLambda.assertions().isCalledExactly(2)
+ .withSequence(
+ listOf(value(A_SESSION_ID), value(A_ROOM_ID)),
+ listOf(value(A_SESSION_ID), value(A_ROOM_ID_3)),
+ )
assertThat(analyticsService.capturedEvents).containsExactly(
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle),
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle),
@@ -594,7 +585,7 @@ class RoomListPresenterTest {
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
- val roomSummary = aRoomSummaryFilled(
+ val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
@@ -610,7 +601,7 @@ class RoomListPresenterTest {
}.last()
val roomListRoomSummary = state.contentAsRooms().summaries.first {
- it.id == roomSummary.identifier()
+ it.id == roomSummary.roomId.value
}
state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary))
@@ -626,6 +617,37 @@ class RoomListPresenterTest {
}
}
+ @Test
+ fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest {
+ val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> }
+ val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
+ val scope = CoroutineScope(coroutineContext + SupervisorJob())
+ val matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ )
+ val roomSummary = aRoomSummary(
+ currentUserMembership = CurrentUserMembership.INVITED
+ )
+ roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
+ roomListService.postAllRooms(listOf(roomSummary))
+ val presenter = createRoomListPresenter(
+ coroutineScope = scope,
+ client = matrixClient,
+ )
+ presenter.test {
+ val state = consumeItemsUntilPredicate {
+ it.contentState is RoomListContentState.Rooms
+ }.last()
+
+ state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10)))
+ subscribeToVisibleRoomsLambda.assertions().isCalledOnce()
+
+ // If called again, it will cancel the current one, which should not result in a test failure
+ state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11)))
+ subscribeToVisibleRoomsLambda.assertions().isCalledExactly(2)
+ }
+ }
+
private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
@@ -643,6 +665,7 @@ class RoomListPresenterTest {
filtersPresenter: Presenter = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter = Presenter { aRoomListSearchState() },
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
+ notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
) = RoomListPresenter(
client = client,
networkMonitor = networkMonitor,
@@ -670,5 +693,6 @@ class RoomListPresenterTest {
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = FakeFullScreenIntentPermissionsPresenter(),
+ notificationCleaner = notificationCleaner,
)
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
index 99045cd783..09cb6a8019 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
@@ -44,6 +44,24 @@ import org.junit.runner.RunWith
class RoomListViewTest {
@get:Rule val rule = createAndroidComposeRule()
+ @Test
+ fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setRoomListView(
+ state = aRoomListState(
+ contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
+ eventSink = eventsRecorder,
+ )
+ )
+
+ eventsRecorder.assertList(
+ listOf(
+ RoomListEvents.UpdateVisibleRange(IntRange.EMPTY),
+ RoomListEvents.UpdateVisibleRange(0 until 2),
+ )
+ )
+ }
+
@Test
fun `clicking on close recovery key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder()
@@ -53,6 +71,10 @@ class RoomListViewTest {
eventSink = eventsRecorder,
)
)
+
+ // Remove automatic initial events
+ eventsRecorder.clear()
+
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvents.DismissRecoveryKeyPrompt)
@@ -60,7 +82,7 @@ class RoomListViewTest {
@Test
fun `clicking on continue recovery key banner invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
+ val eventsRecorder = EventsRecorder()
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
@@ -69,7 +91,13 @@ class RoomListViewTest {
),
onConfirmRecoveryKeyClick = callback,
)
+
+ // Remove automatic initial events
+ eventsRecorder.clear()
+
rule.clickOn(CommonStrings.action_continue)
+
+ eventsRecorder.assertEmpty()
}
}
@@ -90,7 +118,7 @@ class RoomListViewTest {
@Test
fun `clicking on a room invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
+ val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
)
@@ -102,8 +130,14 @@ class RoomListViewTest {
state = state,
onRoomClick = callback,
)
+
+ // Remove automatic initial events
+ eventsRecorder.clear()
+
rule.onNodeWithText(room0.lastMessage!!.toString()).performClick()
}
+
+ eventsRecorder.assertEmpty()
}
@Test
@@ -118,6 +152,9 @@ class RoomListViewTest {
rule.setRoomListView(
state = state,
)
+ // Remove automatic initial events
+ eventsRecorder.clear()
+
rule.onNodeWithText(room0.lastMessage!!.toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0))
}
@@ -135,8 +172,13 @@ class RoomListViewTest {
state = state,
onRoomSettingsClick = callback,
)
+
+ // Remove automatic initial events
+ eventsRecorder.clear()
+
rule.clickOn(CommonStrings.common_settings)
}
+
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
}
@@ -150,6 +192,10 @@ class RoomListViewTest {
it.displayType == RoomSummaryDisplayType.INVITE
}
rule.setRoomListView(state = state)
+
+ // Remove automatic initial events
+ eventsRecorder.clear()
+
rule.clickOn(CommonStrings.action_accept)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt
index 89c843e222..d1fe481e77 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt
@@ -28,8 +28,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
-import io.element.android.libraries.matrix.api.roomlist.RoomSummary
-import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
+import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
@@ -117,10 +116,7 @@ class RoomListSearchPresenterTest {
assertThat(state.results).isEmpty()
}
roomListService.postAllRooms(
- listOf(
- RoomSummary.Empty("1"),
- aRoomSummaryFilled()
- )
+ listOf(aRoomSummary())
)
awaitItem().let { state ->
assertThat(state.results).hasSize(1)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
index e03aecad43..f54bfaee96 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
@@ -81,7 +81,7 @@ class SecureBackupFlowNode @AssistedInject constructor(
data object CreateNewRecoveryKey : NavTarget
}
- private val callback = plugins().firstOrNull()
+ private val callbacks = plugins()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
@@ -130,8 +130,8 @@ class SecureBackupFlowNode @AssistedInject constructor(
NavTarget.EnterRecoveryKey -> {
val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
override fun onEnterRecoveryKeySuccess() {
- if (callback != null) {
- callback.onDone()
+ if (callbacks.isNotEmpty()) {
+ callbacks.forEach { it.onDone() }
} else {
backstack.pop()
}
diff --git a/features/securebackup/impl/src/main/res/values-ka/translations.xml b/features/securebackup/impl/src/main/res/values-ka/translations.xml
index e31153859c..6c1ed02eb5 100644
--- a/features/securebackup/impl/src/main/res/values-ka/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-ka/translations.xml
@@ -22,10 +22,14 @@
"აღდგენის გასაღები შეიცვალა"
"გსურთ აღდგენის გასაღების შეცვლა?"
"დარწმუნდით, რომ ვერავინ ხედავს ამ ეკრანს!"
+ "გთხოვთ, სცადოთ ხელახლა, რათა თქვენი ჩეთის სარეზერვო ასლაზე წვდომა დაადასტუროთ"
+ "აღდგენის არასწორი გასაღები"
"თუ თქვენ გაქვთ უსაფრთხოების გასაღები ან უსაფრთხოების ფრაზა, ეს ასევე იმუშავებს."
"შეყვანა"
"აღდგენის გასაღები დადასტურებულია"
"შეიყვანეთ თქვენი აღდგენის გასაღები"
+ "დაკოპირებულია აღდგენის გასაღები"
+ "გენერირება…"
"აღდგენის გასაღების შენახვა"
"ჩაწერეთ თქვენი აღდგენის გასაღები სადმე უსაფრთხო ადგილას ან შეინახეთ პაროლის მენეჯერში."
"აღდგენის გასაღების დასაკოპირებლად, დააწკაპუნეთ"
diff --git a/features/securebackup/impl/src/main/res/values-pl/translations.xml b/features/securebackup/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..ca1589c5ac
--- /dev/null
+++ b/features/securebackup/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,44 @@
+
+
+ "Wyłącz backup"
+ "Włącz backup"
+ "Backup zapewnia, że nie stracisz swojej historii wiadomości. %1$s"
+ "Backup"
+ "Zmień klucz przywracania"
+ "Wprowadź klucz przywracania"
+ "Backup czatu nie jest zsynchronizowany."
+ "Skonfiguruj przywracanie"
+ "Uzyskaj dostęp do swoich wiadomości szyfrowanych, jeśli utracisz wszystkie swoje urządzenia lub zostaniesz wylogowany z %1$s."
+ "Wyłącz"
+ "Utracisz dostęp do wiadomości szyfrowanych, jeśli zostaniesz wylogowany ze wszystkich urządzeń."
+ "Czy na pewno chcesz wyłączyć backup?"
+ "Wyłączenie backupu spowoduje usunięcie kopii klucza szyfrowania i wyłączenie innych funkcji bezpieczeństwa. W takim przypadku będziesz:"
+ "Posiadał historii wiadomości szyfrowanych na nowych urządzeniach"
+ "Utracisz dostęp do wiadomości szyfrowanych, jeśli zostaniesz wszędzie wylogowany z %1$s"
+ "Czy na pewno chcesz wyłączyć backup?"
+ "Uzyskaj nowy klucz przywracania, jeśli straciłeś dostęp do obecnego. Po zmianie klucza przywracania stary nie będzie już działał."
+ "Generuj nowy klucz przywracania"
+ "Upewnij się, że klucz przywracania będzie trzymany w bezpiecznym miejscu"
+ "Zmieniono klucz przywracania"
+ "Zmienić klucz przywracania?"
+ "Upewnij się, że nikt nie widzi tego ekranu!"
+ "Spróbuj ponownie, aby potwierdzić dostęp do backupu czatu."
+ "Nieprawidłowy klucz przywracania"
+ "To też zadziała, jeśli posiadasz klucz lub frazę bezpieczeństwa."
+ "Wprowadź…"
+ "Potwierdzono klucz przywracania"
+ "Wprowadź klucz przywracania"
+ "Skopiowano klucz przywracania"
+ "Generuję…"
+ "Zapisz klucz przywracania"
+ "Zapisz klucz przywracania w bezpiecznym miejscu lub zapisz go w menedżerze haseł."
+ "Stuknij, by skopiować klucz przywracania"
+ "Zapisz klucz przywracania"
+ "Po tym kroku nie będziesz mieć dostępu do nowego klucza przywracania."
+ "Czy zapisałeś swój klucz przywracania?"
+ "Backup czatu jest chroniony przez klucz przywracania. Jeśli potrzebujesz utworzyć nowy klucz, możesz to zrobić wybierając `Zmień klucz przywracania`."
+ "Wygeneruj klucz przywracania"
+ "Upewnij się, że klucz przywracania możesz przechowywać w bezpiecznym miejscu"
+ "Skonfigurowano przywracanie pomyślnie"
+ "Skonfiguruj przywracanie"
+
diff --git a/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..786de3f4da
--- /dev/null
+++ b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,40 @@
+
+
+ "Desativar o backup"
+ "Ativar o backup"
+ "O backup garante que você não perca seu histórico de mensagens. %1$s."
+ "Backup"
+ "Mudar chave de recuperação"
+ "Insira a chave de recuperação"
+ "Seu backup das conversas está atualmente fora de sincronia."
+ "Configurar a recuperação"
+ "Tenha acesso às suas mensagens criptografadas se você perder todos os seus dispositivos ou for desconectado do %1$s em qualquer lugar."
+ "Desligar"
+ "Você perderá suas mensagens criptografadas se estiver desconectado de todos os dispositivos."
+ "Tem certeza de que deseja desativar o backup?"
+ "Desativar o backup removerá o backup da chave de criptografia atual e desativará outros recursos de segurança. Neste caso, você irá:"
+ "Não ter histórico de mensagens criptografadas em novos dispositivos"
+ "Perder o acesso às suas mensagens criptografadas se você estiver desconectado %1$s em todos os lugares"
+ "Tem certeza de que deseja desativar o backup?"
+ "Obtenha uma nova chave de recuperação caso tenha perdido a existente. Depois de alterar sua chave de recuperação, a antiga não funcionará mais."
+ "Gere uma nova chave de recuperação"
+ "Certifique-se de que você pode armazenar sua chave de recuperação em algum lugar seguro"
+ "Chave de recuperação alterada"
+ "Alterar chave de recuperação?"
+ "Certifique-se de que ninguém possa ver essa tela!"
+ "Se você tiver uma chave de segurança ou frase de segurança, isso também funcionará."
+ "Inserir…"
+ "Chave de recuperação confirmada"
+ "Insira sua chave de recuperação"
+ "Salvar chave de recuperação"
+ "Anote sua chave de recuperação em algum lugar seguro ou salve-a em um gerenciador de senhas."
+ "Toque para copiar a chave de recuperação"
+ "Salve sua chave de recuperação"
+ "Você não poderá acessar sua nova chave de recuperação após essa etapa."
+ "Você salvou sua chave de recuperação?"
+ "Seu backup das conversas é protegido por uma chave de recuperação. Se precisar de uma nova chave de recuperação após a configuração, você pode recriá-la selecionando “Alterar chave de recuperação”."
+ "Gere sua chave de recuperação"
+ "Certifique-se de que você pode armazenar sua chave de recuperação em algum lugar seguro"
+ "Configuração de recuperação bem-sucedida"
+ "Configurar a recuperação"
+
diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
index 42c053bafb..8c91ea530c 100644
--- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
+++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
@@ -92,7 +93,9 @@ class SharePresenterTest {
@Test
fun `present - send text ok`() = runTest {
- val matrixRoom = FakeMatrixRoom()
+ val matrixRoom = FakeMatrixRoom(
+ sendMessageResult = { _, _, _ -> Result.success(Unit) },
+ )
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, matrixRoom)
}
@@ -117,7 +120,9 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
- val matrixRoom = FakeMatrixRoom()
+ val matrixRoom = FakeMatrixRoom(
+ sendMediaResult = { Result.success(FakeMediaUploadHandler()) },
+ )
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, matrixRoom)
}
diff --git a/features/signedout/impl/src/main/res/values-pl/translations.xml b/features/signedout/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..9b83e7d692
--- /dev/null
+++ b/features/signedout/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Zmieniono hasło w innej sesji"
+ "Sesja została usunięta z innej sesji"
+ "Administrator serwera unieważnił Twój dostęp"
+ "Mogłeś zostać wylogowany z powodów wymienionych poniżej. Zaloguj się ponownie, aby dalej korzystać z %s."
+ "Zostałeś wylogowany"
+
diff --git a/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml b/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..3c31806492
--- /dev/null
+++ b/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Você alterou sua senha em outra sessão"
+ "Você excluiu essa sessão através de outra sessão"
+ "O administrador do seu servidor invalidou seu acesso"
+ "Você pode ter sido desconectado por um dos motivos listados abaixo. Faça login novamente para continuar usando %s."
+ "Você está desconectado"
+
diff --git a/features/userprofile/shared/src/main/res/values-pl/translations.xml b/features/userprofile/shared/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..8ee9d296ac
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-pl/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Zablokuj"
+ "Zablokowani użytkownicy nie będą mogli wysyłać Ci wiadomości, a wszystkie ich wiadomości zostaną ukryte. Możesz odblokować ich w dowolnym momencie."
+ "Zablokuj użytkownika"
+ "Odblokuj"
+ "Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."
+ "Odblokuj użytkownika"
+ "Wystąpił błąd podczas próby rozpoczęcia czatu"
+
diff --git a/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml b/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..d5a26a77ca
--- /dev/null
+++ b/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Bloquear"
+ "Usuários bloqueados não poderão enviar mensagens para você e todas as mensagens deles serão ocultadas. Você pode desbloqueá-los a qualquer momento."
+ "Bloquear usuário"
+ "Desbloquear"
+ "Você poderá ver todas as mensagens deles novamente."
+ "Desbloquear usuário"
+ "Ocorreu um erro ao tentar iniciar um chat"
+
diff --git a/features/verifysession/impl/src/main/res/values-et/translations.xml b/features/verifysession/impl/src/main/res/values-et/translations.xml
index efe615fa46..219c7653e1 100644
--- a/features/verifysession/impl/src/main/res/values-et/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-et/translations.xml
@@ -8,9 +8,9 @@
"Kasuta mõnda muud seadet"
"Ootame teise seadme järgi…"
"Olukord pole päris õige. Päring kas aegus või teine osapool keeldus päringule vastamast."
- "Kinnita, et kõik alljärgnevas kuvatud emojid on täpselt samad, mida sa näed oma teises sessioonis."
+ "Kinnita, et kõik järgnevalt kuvatud emojid on täpselt samad, mida sa näed oma teises sessioonis."
"Võrdle emojisid"
- "Kinnita, et kõik alljärgnevas kuvatud numbrid on täpselt samad, mida sa näed oma teises sessioonis."
+ "Kinnita, et kõik järgnevalt kuvatud numbrid on täpselt samad, mida sa näed oma teises sessioonis."
"Võrdle numbreid"
"Sinu uus sessioon on nüüd verifitseeritud. Sellel sessioonil on nüüd ligipääs sinu krüptitud sõnumitele ja teised osapooled näevad teda usaldusväärsena."
"Sisesta taastevõti"
diff --git a/features/verifysession/impl/src/main/res/values-ka/translations.xml b/features/verifysession/impl/src/main/res/values-ka/translations.xml
index fb1f3c6db4..afd1b03a56 100644
--- a/features/verifysession/impl/src/main/res/values-ka/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-ka/translations.xml
@@ -3,12 +3,15 @@
"რაღაცა არასწორადაა. ან მოთხოვნის ვადაა ამოწურული, ან მოთხოვნა უარყოფილი იყო."
"დაადასტურეთ, რომ ქვემოთ მოყვანილი ემოჯიები შეესაბამება თქვენს სხვა სესიაზე ნაჩვენებს."
"შეადარეთ ემოჯიები"
+ "დაადასტურეთ, რომ ქვემოთ მოცემული ნომრები ემთხვევა თქვენს სხვა სესიაზე ნაჩვენები ნომრებს."
+ "შეადარეთ რიცხვები"
"თქვენი ახალი სესია დადასტურებულია. მას აქვს წვდომა დაშიფრულ შეტყობინებებზე და სხვა მომხმარებლები მას სანდოდ ხედავენ."
"დაამტკიცეთ, რომ ეს თქვენ ხართ, რათა მიიღოთ წვდომა თქვენი დაშიფრული შეტყობინებების ისტორიასთან."
"არსებული სესიის გახსნა"
"დადასტურების ხელახლა ცდა"
"მზად ვარ"
"ველოდებით დამთხვევას"
+ "შეადარეთ ემოციების უნიკალური ნაკრები."
"შეადარეთ უნიკალური ემოჯი, დარწმუნდით, რომ ისინი ერთი დ იმავე თანმიმდევრობით გამოჩნდნენ."
"ისინი არ ემთხვევიან ერთმანეთს"
"ისინი ემთხვევიან ერთმანეთს"
diff --git a/features/verifysession/impl/src/main/res/values-pl/translations.xml b/features/verifysession/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..6f39abf905
--- /dev/null
+++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,20 @@
+
+
+ "Coś tu nie gra. Albo upłynął limit czasu, albo żądanie zostało odrzucone."
+ "Upewnij się, że poniższe emotikony pasują do tych wyświetlanych na innej sesji."
+ "Porównaj emotki"
+ "Upewnij się, że liczby poniżej pasują do tych wyświetlanych na innej sesji."
+ "Porównaj liczby"
+ "Twoja nowa sesja jest teraz zweryfikowana. Ma ona dostęp do Twoich zaszyfrowanych wiadomości, a inni użytkownicy będą widzieć ją jako zaufaną."
+ "Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości."
+ "Otwórz istniejącą sesję"
+ "Ponów weryfikację"
+ "Jestem gotowy(a)"
+ "Oczekiwanie na dopasowanie"
+ "Porównaj unikalny zestaw emoji."
+ "Porównaj unikalne emoji, upewniając się, że pojawiły się w tej samej kolejności."
+ "Nie pasują do siebie"
+ "Pasują do siebie"
+ "Zaakceptuj prośbę o rozpoczęcie procesu weryfikacji w innej sesji, aby kontynuować."
+ "Oczekiwanie na zaakceptowanie żądania"
+
diff --git a/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..9d4658be82
--- /dev/null
+++ b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Algo não parece certo. Ou a solicitação atingiu o tempo limite ou a solicitação foi negada."
+ "Confirme se os emojis abaixo correspondem aos mostrados em sua outra sessão."
+ "Compare os emojis"
+ "Sua nova sessão está agora verificada. Ela tem acesso às suas mensagens criptografadas e outros usuários a verão como confiável."
+ "Prove que é você para acessar seu histórico de mensagens criptografadas."
+ "Abrir uma sessão existente"
+ "Repetir verificação"
+ "Estou pronto"
+ "Esperando para combinar"
+ "Compare os emojis únicos, garantindo que apareçam na mesma ordem."
+ "Eles não combinam"
+ "Eles combinam"
+ "Aceite a solicitação para iniciar o processo de verificação em sua outra sessão para continuar."
+ "Aguardando para aceitar a solicitação"
+
diff --git a/gradle.properties b/gradle.properties
index 9808f9eda1..b1f6468108 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -49,7 +49,7 @@ signing.element.nightly.keyPassword=Secret
# Customise the Lint version to use a more recent version than the one bundled with AGP
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
-android.experimental.lint.version=8.5.0-alpha07
+android.experimental.lint.version=8.7.0-alpha01
# Enable test fixture for all modules by default
android.experimental.enableTestFixtures=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 51760bb7c5..a1fede3e54 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -37,15 +37,15 @@ accompanist = "0.34.0"
test_core = "1.6.1"
#other
-coil = "2.6.0"
+coil = "2.7.0"
datetime = "0.6.0"
dependencyAnalysis = "1.32.0"
serialization_json = "1.6.3"
showkase = "1.0.3"
appyx = "1.4.0"
sqldelight = "2.0.2"
-wysiwyg = "2.37.4"
-telephoto = "0.11.2"
+wysiwyg = "2.37.7"
+telephoto = "0.12.1"
# DI
dagger = "2.51.1"
@@ -67,9 +67,10 @@ kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:33.1.1"
+google_firebase_bom = "com.google.firebase:firebase-bom:33.1.2"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
+oss_licenses_plugin = "com.google.android.gms:oss-licenses-plugin:0.10.6"
# AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" }
@@ -138,12 +139,12 @@ test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" }
test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.1"
-test_mockk = "io.mockk:mockk:1.13.11"
+test_mockk = "io.mockk:mockk:1.13.12"
test_konsist = "com.lemonappdev:konsist:0.15.1"
test_turbine = "app.cash.turbine:turbine:1.1.0"
-test_truth = "com.google.truth:truth:1.4.3"
+test_truth = "com.google.truth:truth:1.4.4"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.16"
-test_robolectric = "org.robolectric:robolectric:4.12.2"
+test_robolectric = "org.robolectric:robolectric:4.13"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
test_composable_preview_scanner = "com.github.sergio-sastre.ComposablePreviewScanner:android:0.1.2"
@@ -152,17 +153,17 @@ coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
-compound = { module = "io.element.android:compound-android", version = "0.0.6" }
+compound = { module = "io.element.android:compound-android", version = "0.0.7" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7"
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
-jsoup = "org.jsoup:jsoup:1.17.2"
+jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.30"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.34"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -180,12 +181,13 @@ maplibre = "org.maplibre.gl:android-sdk:11.0.1"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0"
opusencoder = "io.element.android:opusencoder:1.1.0"
-kotlinpoet = "com.squareup:kotlinpoet:1.17.0"
+kotlinpoet = "com.squareup:kotlinpoet:1.18.1"
zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
+play_services_oss_licenses = "com.google.android.gms:play-services-oss-licenses:17.1.0"
# Analytics
posthog = "com.posthog:posthog-android:3.4.2"
-sentry = "io.sentry:sentry-android:7.11.0"
+sentry = "io.sentry:sentry-android:7.12.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1"
@@ -224,10 +226,10 @@ anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.6"
ktlint = "org.jlleitschuh.gradle.ktlint:12.1.1"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
-dependencycheck = "org.owasp.dependencycheck:10.0.1"
+dependencycheck = "org.owasp.dependencycheck:10.0.3"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
paparazzi = "app.cash.paparazzi:1.3.4"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
-sonarqube = "org.sonarqube:5.0.0.4638"
+sonarqube = "org.sonarqube:5.1.0.4882"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 515ab9d5f1..68e8816d71 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=f8b4f4772d302c8ff580bc40d0f56e715de69b163546944f787c87abf209c961
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-all.zip
+distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/libraries/androidutils/src/main/res/values-pl/translations.xml b/libraries/androidutils/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..6302f19472
--- /dev/null
+++ b/libraries/androidutils/src/main/res/values-pl/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Nie znaleziono kompatybilnej aplikacji do obsługi tej akcji."
+
diff --git a/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml b/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..cde5b743f6
--- /dev/null
+++ b/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Nenhum aplicativo compatível foi encontrado para lidar com essa ação."
+
diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts
index 9934da3e13..e737220961 100644
--- a/libraries/designsystem/build.gradle.kts
+++ b/libraries/designsystem/build.gradle.kts
@@ -42,6 +42,7 @@ android {
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt
index a1172b47fa..4c6d8ecc8c 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt
@@ -18,12 +18,12 @@ package io.element.android.libraries.designsystem.components.button
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalContentColor
@@ -33,6 +33,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.Hyphens
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -61,7 +64,7 @@ fun MainActionButton(
onClick = onClick,
indication = ripple
)
- .widthIn(min = 76.dp),
+ .widthIn(min = 76.dp, max = 96.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val tintColor = if (enabled) LocalContentColor.current else MaterialTheme.colorScheme.secondary
@@ -73,8 +76,10 @@ fun MainActionButton(
Spacer(modifier = Modifier.height(14.dp))
Text(
title,
- style = ElementTheme.typography.fontBodyMdMedium,
+ style = ElementTheme.typography.fontBodyMdMedium.copy(hyphens = Hyphens.Auto),
color = tintColor,
+ overflow = TextOverflow.Visible,
+ textAlign = TextAlign.Center,
)
}
}
@@ -89,13 +94,20 @@ internal fun MainActionButtonPreview() {
@Composable
private fun ContentsToPreview() {
- Row(Modifier.padding(10.dp)) {
+ Row(
+ modifier = Modifier.padding(10.dp),
+ horizontalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
MainActionButton(
title = "Share",
imageVector = CompoundIcons.ShareAndroid(),
onClick = { },
)
- Spacer(modifier = Modifier.width(20.dp))
+ MainActionButton(
+ title = "Share with a long text",
+ imageVector = CompoundIcons.ShareAndroid(),
+ onClick = { },
+ )
MainActionButton(
title = "Share",
imageVector = CompoundIcons.ShareAndroid(),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt
index 56da5f5211..3106043ed8 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt
@@ -53,6 +53,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
+import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue
@OptIn(CoreColorToken::class)
@Composable
@@ -67,7 +68,9 @@ fun SuperButton(
val contentPadding = remember(buttonSize) {
when (buttonSize) {
ButtonSize.Large -> PaddingValues(horizontal = 24.dp, vertical = 13.dp)
+ ButtonSize.LargeLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 13.dp)
ButtonSize.Medium -> PaddingValues(horizontal = 20.dp, vertical = 9.dp)
+ ButtonSize.MediumLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 9.dp)
ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp)
}
}
@@ -144,6 +147,14 @@ internal fun SuperButtonPreview() {
Text("Super button!")
}
+ SuperButton(
+ modifier = Modifier.padding(10.dp),
+ buttonSize = ButtonSize.LargeLowPadding,
+ onClick = {},
+ ) {
+ Text("Super LargeLowPadding")
+ }
+
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Medium,
@@ -152,6 +163,14 @@ internal fun SuperButtonPreview() {
Text("Super button!")
}
+ SuperButton(
+ modifier = Modifier.padding(10.dp),
+ buttonSize = ButtonSize.MediumLowPadding,
+ onClick = {},
+ ) {
+ Text("Super MediumLowPadding")
+ }
+
SuperButton(
modifier = Modifier.padding(10.dp),
buttonSize = ButtonSize.Small,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt
new file mode 100644
index 0000000000..6dc7c41c77
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.theme
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.theme.Theme
+import io.element.android.compound.theme.isDark
+import io.element.android.compound.theme.mapToTheme
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+
+/**
+ * Theme to use for all the regular screens of the application.
+ * Will manage the light / dark theme based on the user preference.
+ * Will also ensure that the system is applying the correct global theme
+ * to the application, especially when the system is light and the application
+ * is forced to use dark theme.
+ */
+@Composable
+fun ElementThemeApp(
+ appPreferencesStore: AppPreferencesStore,
+ content: @Composable () -> Unit,
+) {
+ val theme by remember {
+ appPreferencesStore.getThemeFlow().mapToTheme()
+ }
+ .collectAsState(initial = Theme.System)
+ LaunchedEffect(theme) {
+ AppCompatDelegate.setDefaultNightMode(
+ when (theme) {
+ Theme.System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+ Theme.Light -> AppCompatDelegate.MODE_NIGHT_NO
+ Theme.Dark -> AppCompatDelegate.MODE_NIGHT_YES
+ }
+ )
+ }
+ ElementTheme(
+ darkTheme = theme.isDark(),
+ content = content,
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt
index c4e2f16fc2..fa20c968a9 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt
@@ -58,6 +58,9 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
// Designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&mode=design&t=U03tOFZz5FSLVUMa-1
+// Horizontal padding for button with low padding
+internal val lowHorizontalPaddingValue = 4.dp
+
@Composable
fun Button(
text: String,
@@ -139,8 +142,10 @@ private fun ButtonInternal(
) {
val minHeight = when (size) {
ButtonSize.Small -> 32.dp
- ButtonSize.Medium -> 40.dp
- ButtonSize.Large -> 48.dp
+ ButtonSize.Medium,
+ ButtonSize.MediumLowPadding -> 40.dp
+ ButtonSize.Large,
+ ButtonSize.LargeLowPadding -> 48.dp
}
val hasStartDrawable = showProgress || leadingIcon != null
@@ -166,6 +171,7 @@ private fun ButtonInternal(
PaddingValues(start = 12.dp, top = 10.dp, end = 12.dp, bottom = 10.dp)
}
}
+ ButtonSize.MediumLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 10.dp)
ButtonSize.Large -> when (style) {
ButtonStyle.Filled,
ButtonStyle.Outlined -> if (hasStartDrawable) {
@@ -179,6 +185,7 @@ private fun ButtonInternal(
PaddingValues(start = 16.dp, top = 13.dp, end = 16.dp, bottom = 13.dp)
}
}
+ ButtonSize.LargeLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 13.dp)
}
val shape = when (style) {
@@ -204,8 +211,10 @@ private fun ButtonInternal(
val textStyle = when (size) {
ButtonSize.Small,
- ButtonSize.Medium -> MaterialTheme.typography.labelLarge
- ButtonSize.Large -> ElementTheme.typography.fontBodyLgMedium
+ ButtonSize.Medium,
+ ButtonSize.MediumLowPadding -> MaterialTheme.typography.labelLarge
+ ButtonSize.Large,
+ ButtonSize.LargeLowPadding -> ElementTheme.typography.fontBodyLgMedium
}
androidx.compose.material3.Button(
@@ -270,7 +279,19 @@ sealed interface IconSource {
enum class ButtonSize {
Small,
Medium,
- Large
+
+ /**
+ * Like [Medium] but with minimal horizontal padding, so that large texts have less risk to get truncated.
+ * To be used for instance for button with weight which ensures a maximal width.
+ */
+ MediumLowPadding,
+ Large,
+
+ /**
+ * Like [Large] but with minimal horizontal padding, so that large texts have less risk to get truncated.
+ * To be used for instance for button with weight which ensures a maximal width.
+ */
+ LargeLowPadding,
}
internal enum class ButtonStyle {
@@ -345,6 +366,15 @@ internal fun FilledButtonMediumPreview() {
)
}
+@Preview(group = PreviewGroup.Buttons)
+@Composable
+internal fun FilledButtonMediumLowPaddingPreview() {
+ ButtonCombinationPreview(
+ style = ButtonStyle.Filled,
+ size = ButtonSize.MediumLowPadding,
+ )
+}
+
@Preview(group = PreviewGroup.Buttons)
@Composable
internal fun FilledButtonLargePreview() {
@@ -354,6 +384,15 @@ internal fun FilledButtonLargePreview() {
)
}
+@Preview(group = PreviewGroup.Buttons)
+@Composable
+internal fun FilledButtonLargeLowPaddingPreview() {
+ ButtonCombinationPreview(
+ style = ButtonStyle.Filled,
+ size = ButtonSize.LargeLowPadding,
+ )
+}
+
@Preview(group = PreviewGroup.Buttons)
@Composable
internal fun OutlinedButtonSmallPreview() {
@@ -372,6 +411,15 @@ internal fun OutlinedButtonMediumPreview() {
)
}
+@Preview(group = PreviewGroup.Buttons)
+@Composable
+internal fun OutlinedButtonMediumLowPaddingPreview() {
+ ButtonCombinationPreview(
+ style = ButtonStyle.Outlined,
+ size = ButtonSize.MediumLowPadding,
+ )
+}
+
@Preview(group = PreviewGroup.Buttons)
@Composable
internal fun OutlinedButtonLargePreview() {
@@ -381,6 +429,15 @@ internal fun OutlinedButtonLargePreview() {
)
}
+@Preview(group = PreviewGroup.Buttons)
+@Composable
+internal fun OutlinedButtonLargeLowPaddingPreview() {
+ ButtonCombinationPreview(
+ style = ButtonStyle.Outlined,
+ size = ButtonSize.LargeLowPadding,
+ )
+}
+
@Preview(group = PreviewGroup.Buttons)
@Composable
internal fun TextButtonSmallPreview() {
@@ -399,6 +456,15 @@ internal fun TextButtonMediumPreview() {
)
}
+@Preview(group = PreviewGroup.Buttons)
+@Composable
+internal fun TextButtonMediumLowPaddingPreview() {
+ ButtonCombinationPreview(
+ style = ButtonStyle.Text,
+ size = ButtonSize.MediumLowPadding,
+ )
+}
+
@Preview(group = PreviewGroup.Buttons)
@Composable
internal fun TextButtonLargePreview() {
@@ -408,6 +474,15 @@ internal fun TextButtonLargePreview() {
)
}
+@Preview(group = PreviewGroup.Buttons)
+@Composable
+internal fun TextButtonLargeLowPaddingPreview() {
+ ButtonCombinationPreview(
+ style = ButtonStyle.Text,
+ size = ButtonSize.LargeLowPadding,
+ )
+}
+
@Composable
private fun ButtonCombinationPreview(
style: ButtonStyle,
diff --git a/libraries/designsystem/src/main/res/drawable-night/bg_migration.png b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png
index 41cba4f4ba..442628a6bf 100644
Binary files a/libraries/designsystem/src/main/res/drawable-night/bg_migration.png and b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png differ
diff --git a/libraries/designsystem/src/main/res/drawable/bg_migration.png b/libraries/designsystem/src/main/res/drawable/bg_migration.png
index 2ff7516878..4d889596ab 100644
Binary files a/libraries/designsystem/src/main/res/drawable/bg_migration.png and b/libraries/designsystem/src/main/res/drawable/bg_migration.png differ
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index 428e6d1da1..13655c48f2 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -85,7 +85,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
}
}
is StickerContent -> {
- content.body
+ prefixIfNeeded(sp.getString(CommonStrings.common_sticker) + " (" + content.body + ")", senderDisambiguatedDisplayName, isDmRoom)
}
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
diff --git a/libraries/eventformatter/impl/src/main/res/values-el/translations.xml b/libraries/eventformatter/impl/src/main/res/values-el/translations.xml
index 322842e825..71adc9ed56 100644
--- a/libraries/eventformatter/impl/src/main/res/values-el/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-el/translations.xml
@@ -59,4 +59,5 @@
"Αφαίρεσες το θέμα του δωματίου"
"Ο χρήστης %1$s έκανε άρση αποκλεισμού στον χρήστη %2$s"
"Έκανες άρση αποκλεισμού στον χρήστη %1$s"
+ "Ο χρήστης %1$s έκανε μια άγνωστη αλλαγή στην ιδιότητα μέλους του."
diff --git a/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..0c3bddc3c1
--- /dev/null
+++ b/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,59 @@
+
+
+ "(zdjęcie profilowe też zostało zmienione)"
+ "%1$s zmienił swoje zdjęcie profilowe"
+ "Zmieniłeś swoje zdjęcie profilowe"
+ "%1$s zmienił swoją wyświetlaną nazwę z %2$s na %3$s"
+ "Zmieniłeś swoją wyświetlaną nazwę z %1$s na %2$s"
+ "%1$s usunął swoją wyświetlaną nazwę (byo to %2$s)"
+ "Usunąłeś swoją wyświetlaną nazwę (było to %1$s)"
+ "%1$s ustawił swoją wyświetlaną nazwę na %2$s"
+ "Ustawiłeś swoją wyświetlaną nazwę na %1$s"
+ "%1$s zmienił zdjęcie profilowe pokoju"
+ "Zmieniłeś zdjęcie profilowe pokoju"
+ "%1$s usunął zdjęcie profilowe pokoju"
+ "Usunąłeś zdjęcie profilowe pokoju"
+ "%1$s zbanował %2$s"
+ "Zbanowałeś %1$s"
+ "%1$s stworzył pokój"
+ "Stworzyłeś pokój"
+ "%1$s zaprosił %2$s"
+ "%1$s zaakceptował zaproszenie"
+ "Zaakceptowałeś zaproszenie"
+ "Zaprosiłeś %1$s"
+ "%1$s zaprosił Cię"
+ "%1$s dołączył do pokoju"
+ "Dołączyłeś(aś) do pokoju"
+ "%1$s prosi o możliwość dołączenia"
+ "%1$s zezwolił %2$s na dołączenie"
+ "Zezwoliłeś %1$s na dołączenie"
+ "Poprosiłeś o możliwość dołączenia"
+ "%1$s odrzucił prośbę %2$s o dołączenie"
+ "Odrzuciłeś prośbę %1$s o dołączenie"
+ "%1$s odrzucił Twoją prośbę o dołączenie"
+ "%1$s nie jest już zainteresowany dołączeniem"
+ "Anulowałeś prośbę o dołączenie"
+ "%1$s opuścił pokój"
+ "Opuściłeś pokój"
+ "%1$s zmienił nazwę pokoju na: %2$s"
+ "Zmieniłeś nazwę pokoju na: %1$s"
+ "%1$s usunął nazwę pokoju"
+ "Usunąłeś nazwę pokoju"
+ "%1$s nie wprowadził żadnych zmian"
+ "Nie wprowadzono żadnych zmian"
+ "%1$s odrzucił zaproszenie"
+ "Odrzuciłeś(aś) zaproszenie"
+ "%1$s usunął %2$s"
+ "Usunąłeś %1$s"
+ "%1$s wysłał zaproszenie do %2$s, aby dołączył do pokoju"
+ "Wysłano zaproszenie do %1$s, aby dołączył do pokoju"
+ "%1$s cofnął zaproszenie dla %2$s do tego pokoju"
+ "Odwołano zaproszenie %1$s, aby dołączył do pokoju"
+ "%1$s zmienił temat na: %2$s"
+ "Zmieniłeś temat na: %1$s"
+ "%1$s usunął temat pokoju"
+ "Usunąłeś temat pokoju"
+ "%1$s odbanował %2$s"
+ "Odbanowałeś %1$s"
+ "%1$s dokonał nieznanej zmiany w swoim członkostwie"
+
diff --git a/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 0000000000..8cb375f1b7
--- /dev/null
+++ b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "(o avatar também foi alterado)"
+ "%1$s mudou seu avatar"
+ "Você mudou seu avatar"
+ "%1$s mudou seu nome de exibição de %2$s para %3$s"
+ "Você alterou seu nome de exibição de %1$s para %2$s"
+ "%1$s removeu seu nome de exibição (era %2$s)"
+ "Você removeu seu nome de exibição (era %1$s)"
+ "%1$s definiu seu nome de exibição como %2$s"
+ "Você definiu seu nome de exibição como %1$s"
+ "%1$s mudou o avatar da sala"
+ "Você mudou o avatar da sala"
+ "%1$s removeu o avatar da sala"
+ "Você removeu o avatar da sala"
+ "%1$s baniu %2$s"
+ "Você baniu %1$s"
+ "%1$s criou a sala"
+ "Você criou a sala"
+ "%1$s convidou %2$s"
+ "%1$s aceitou o convite"
+ "Você aceitou o convite"
+ "Você convidou %1$s"
+ "%1$s convidou você"
+ "%1$s entrou na sala"
+ "Você entrou na sala"
+ "%1$s solicitou entrada"
+ "%1$s permitiu que o %2$s entrar"
+ "Você permitiu que o %1$s entrasse"
+ "Você solicitou entrada"
+ "%1$s rejeitou a solicitação de %2$s para entrar"
+ "Você rejeitou a solicitação de %1$s para entrar"
+ "%1$s rejeitou sua solicitação para entrar"
+ "%1$s não está mais interessado em entrar"
+ "Você cancelou seu pedido para entrar"
+ "%1$s saiu da sala"
+ "Você saiu da sala"
+ "%1$s mudou o nome da sala para: %2$s"
+ "Você mudou o nome da sala para: %1$s"
+ "%1$s removeu o nome da sala"
+ "Você removeu o nome da sala"
+ "%1$s rejeitou o convite"
+ "Você rejeitou o convite"
+ "%1$s removido %2$s"
+ "Você removeu %1$s"
+ "%1$s enviou um convite para %2$s para entrar na sala"
+ "Você enviou um convite para %1$s para entrar na sala"
+ "%1$s revogou o convite para %2$s para entrar na sala"
+ "Você revogou o convite para %1$s para entrar na sala"
+ "%1$s mudou o tópico para: %2$s"
+ "Você mudou o tópico para: %1$s"
+ "%1$s removeu o tópico da sala"
+ "Você removeu o tópico da sala"
+ "%1$s desbaniu %2$s"
+ "Você desbaniu %1$s"
+ "%1$s fez uma alteração desconhecida em sua associação"
+
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index 5255fcea4a..5465882479 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -105,11 +105,12 @@ class DefaultRoomLastMessageFormatterTest {
@Test
@Config(qualifiers = "en")
fun `Sticker content`() {
- val body = "body"
+ val body = "a sticker body"
val info = ImageInfo(null, null, null, null, null, null, null)
val message = createRoomEvent(false, null, StickerContent(body, info, aMediaSource(url = "url")))
val result = formatter.format(message, false)
- assertThat(result).isEqualTo(body)
+ val expectedBody = someoneElseId.toString() + ": Sticker (a sticker body)"
+ assertThat(result.toString()).isEqualTo(expectedBody)
}
@Test
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 17ea03f7a9..616a549e63 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -113,4 +113,11 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
+ PictureInPicture(
+ key = "feature.pictureInPicture",
+ title = "Picture in Picture for Calls",
+ description = "Allow the Call to be rendered in PiP mode",
+ defaultValue = { it.buildType != BuildType.RELEASE },
+ isFinished = false,
+ ),
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
index ac77e58986..023fb9322b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt
@@ -16,40 +16,51 @@
package io.element.android.libraries.matrix.api.core
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+
/**
* This class contains pattern to match the different Matrix ids
* Ref: https://matrix.org/docs/spec/appendices#identifier-grammar
*/
object MatrixPatterns {
// Note: TLD is not mandatory (localhost, IP address...)
- private const val DOMAIN_REGEX = ":[A-Z0-9.-]+(:[0-9]{2,5})?"
+ private const val DOMAIN_REGEX = ":[A-Za-z0-9.-]+(:[0-9]{2,5})?"
+
+ private const val BASE_64_ALPHABET = "[0-9A-Za-z/\\+=]+"
+ private const val BASE_64_URL_SAFE_ALPHABET = "[0-9A-Za-z/\\-_]+"
// regex pattern to find matrix user ids in a string.
// See https://matrix.org/docs/spec/appendices#historical-user-ids
// Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec.
- private const val MATRIX_USER_IDENTIFIER_REGEX = "^@.*?$DOMAIN_REGEX$"
- private val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
+ // Note: local part can be empty
+ private const val MATRIX_USER_IDENTIFIER_REGEX = "^@\\S*?$DOMAIN_REGEX$"
+ private val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex()
- // regex pattern to find room ids in a string.
- private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9.-]+$DOMAIN_REGEX"
- private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
+ // regex pattern to match room ids.
+ // Note: roomId can be arbitrary strings, including space and new line char
+ private const val MATRIX_ROOM_IDENTIFIER_REGEX = "^!.+$DOMAIN_REGEX$"
+ private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.DOT_MATCHES_ALL)
- // regex pattern to find room aliases in a string.
- private const val MATRIX_ROOM_ALIAS_REGEX = "#[A-Z0-9._%#@=+-]+$DOMAIN_REGEX"
+ // regex pattern to match room aliases.
+ private const val MATRIX_ROOM_ALIAS_REGEX = "^#\\S+$DOMAIN_REGEX$"
private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE)
- // regex pattern to find message ids in a string.
+ // regex pattern to match event ids.
// Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec.
+ // v1 and v2: arbitrary string + domain
private const val MATRIX_EVENT_IDENTIFIER_REGEX = "^\\$.+$DOMAIN_REGEX$"
- private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
+ private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex()
- // regex pattern to find message ids in a string.
- private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$[A-Z0-9/+]+"
- private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex(RegexOption.IGNORE_CASE)
+ // v3: base64
+ private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$$BASE_64_ALPHABET"
+ private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex()
- // Ref: https://matrix.org/docs/spec/rooms/v4#event-ids
- private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$[A-Z0-9\\-_]+"
- private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex(RegexOption.IGNORE_CASE)
+ // v4: url-safe base64
+ private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$$BASE_64_URL_SAFE_ALPHABET"
+ private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex()
+
+ private const val MAX_IDENTIFIER_LENGTH = 255
/**
* Tells if a string is a valid user Id.
@@ -58,7 +69,9 @@ object MatrixPatterns {
* @return true if the string is a valid user id
*/
fun isUserId(str: String?): Boolean {
- return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER
+ return str != null &&
+ str.length <= MAX_IDENTIFIER_LENGTH &&
+ str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER
}
/**
@@ -76,7 +89,9 @@ object MatrixPatterns {
* @return true if the string is a valid room Id
*/
fun isRoomId(str: String?): Boolean {
- return str != null && str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER
+ return str != null &&
+ str.length <= MAX_IDENTIFIER_LENGTH &&
+ str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER
}
/**
@@ -86,7 +101,9 @@ object MatrixPatterns {
* @return true if the string is a valid room alias.
*/
fun isRoomAlias(str: String?): Boolean {
- return str != null && str matches PATTERN_CONTAIN_MATRIX_ALIAS
+ return str != null &&
+ str.length <= MAX_IDENTIFIER_LENGTH &&
+ str matches PATTERN_CONTAIN_MATRIX_ALIAS
}
/**
@@ -97,9 +114,10 @@ object MatrixPatterns {
*/
fun isEventId(str: String?): Boolean {
return str != null &&
- (str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER ||
+ str.length <= MAX_IDENTIFIER_LENGTH &&
+ (str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 ||
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 ||
- str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4)
+ str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER)
}
/**
@@ -109,4 +127,56 @@ object MatrixPatterns {
* @return true if the string is a valid thread id.
*/
fun isThreadId(str: String?) = isEventId(str)
+
+ /**
+ * Finds existing ids or aliases in a [CharSequence].
+ * Note not all cases are implemented.
+ */
+ fun findPatterns(text: CharSequence, permalinkParser: PermalinkParser): List {
+ val rawTextMatches = "\\S+$DOMAIN_REGEX".toRegex(RegexOption.IGNORE_CASE).findAll(text)
+ val urlMatches = "\\[\\S+\\]\\((\\S+)\\)".toRegex(RegexOption.IGNORE_CASE).findAll(text)
+ val atRoomMatches = Regex("@room").findAll(text)
+ return buildList {
+ for (match in rawTextMatches) {
+ // Match existing id and alias patterns in the text
+ val type = when {
+ isUserId(match.value) -> MatrixPatternType.USER_ID
+ isRoomId(match.value) -> MatrixPatternType.ROOM_ID
+ isRoomAlias(match.value) -> MatrixPatternType.ROOM_ALIAS
+ isEventId(match.value) -> MatrixPatternType.EVENT_ID
+ else -> null
+ }
+ if (type != null) {
+ add(MatrixPatternResult(type, match.value, match.range.first, match.range.last + 1))
+ }
+ }
+ for (match in urlMatches) {
+ // Extract the link and check if it's a valid permalink
+ val urlMatch = match.groupValues[1]
+ when (val permalink = permalinkParser.parse(urlMatch)) {
+ is PermalinkData.UserLink -> {
+ add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.toString(), match.range.first, match.range.last + 1))
+ }
+ is PermalinkData.RoomLink -> {
+ add(MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, permalink.roomIdOrAlias.identifier, match.range.first, match.range.last + 1))
+ }
+ else -> Unit
+ }
+ }
+ for (match in atRoomMatches) {
+ // Special case for `@room` mentions
+ add(MatrixPatternResult(MatrixPatternType.AT_ROOM, match.value, match.range.first, match.range.last + 1))
+ }
+ }
+ }
}
+
+enum class MatrixPatternType {
+ USER_ID,
+ ROOM_ID,
+ ROOM_ALIAS,
+ EVENT_ID,
+ AT_ROOM
+}
+
+data class MatrixPatternResult(val type: MatrixPatternType, val value: String, val start: Int, val end: Int)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
index 55bce7c5f8..1696375bcb 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
@@ -33,6 +33,7 @@ data class NotificationData(
val roomAvatarUrl: String?,
val roomDisplayName: String?,
val isDirect: Boolean,
+ val isDm: Boolean,
val isEncrypted: Boolean,
val isNoisy: Boolean,
val timestamp: Long,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
index 76bb327c2e..5e562f43c5 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
@@ -16,12 +16,15 @@
package io.element.android.libraries.matrix.api.permalink
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
interface PermalinkBuilder {
fun permalinkForUser(userId: UserId): Result
+ fun permalinkForRoomAlias(roomAlias: RoomAlias): Result
}
sealed class PermalinkBuilderError : Throwable() {
data object InvalidUserId : PermalinkBuilderError()
+ data object InvalidRoomAlias : PermalinkBuilderError()
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index 44a611a43e..378ada5b16 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -57,9 +57,6 @@ interface MatrixRoom : Closeable {
val activeMemberCount: Long
val joinedMemberCount: Long
- /** Whether the room is a direct message. */
- val isDm: Boolean get() = isDirect && isOneToOne
-
val roomInfoFlow: Flow
val roomTypingMembersFlow: Flow>
@@ -113,8 +110,6 @@ interface MatrixRoom : Closeable {
suspend fun subscribeToSync()
- suspend fun unsubscribeFromSync()
-
suspend fun powerLevels(): Result
suspend fun updatePowerLevels(matrixRoomPowerLevels: MatrixRoomPowerLevels): Result
@@ -131,6 +126,8 @@ interface MatrixRoom : Closeable {
suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result
+ suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result
+
suspend fun sendImage(
file: File,
thumbnailFile: File?,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheck.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheck.kt
new file mode 100644
index 0000000000..1cc665ede9
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheck.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room
+
+/**
+ * Returns whether the room with the provided info is a DM.
+ * A DM is a room with at most 2 active members (one of them may have left).
+ *
+ * @param isDirect true if the room is direct
+ * @param activeMembersCount the number of active members in the room (joined or invited)
+ */
+fun isDm(isDirect: Boolean, activeMembersCount: Int): Boolean {
+ return isDirect && activeMembersCount <= 2
+}
+
+/**
+ * Returns whether the [MatrixRoom] is a DM.
+ */
+val MatrixRoom.isDm get() = isDm(isDirect, activeMemberCount.toInt())
+
+/**
+ * Returns whether the [MatrixRoomInfo] is from a DM.
+ */
+val MatrixRoomInfo.isDm get() = isDm(isDirect, activeMembersCount.toInt())
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt
index c2fb147aa0..e97c903e07 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.first
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
index ca2d21a706..c98b05d192 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.roomlist
import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterIsInstance
@@ -54,16 +55,16 @@ interface RoomListService {
): DynamicRoomList
/**
- * Returns a [DynamicRoomList] object of all rooms we want to display.
- * If you want to get a filtered room list, consider using [createRoomList].
+ * Subscribes to sync requests for the visible rooms.
+ * @param roomIds the list of visible room ids to subscribe to.
*/
- val allRooms: DynamicRoomList
+ suspend fun subscribeToVisibleRooms(roomIds: List)
/**
- * Will set the visible range of all rooms.
- * This is useful to load more data when the user scrolls down.
+ * Returns a [DynamicRoomList] object of all rooms we want to display.
+ * If you want to get a filtered room list, consider using [createRoomList].
*/
- fun updateAllRoomsVisibleRange(range: IntRange)
+ val allRooms: DynamicRoomList
/**
* The sync indicator as a flow.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
index 1e38b00b61..6841af9721 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
@@ -24,19 +24,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
-sealed interface RoomSummary {
- data class Empty(val identifier: String) : RoomSummary
- data class Filled(val details: RoomSummaryDetails) : RoomSummary
-
- fun identifier(): String {
- return when (this) {
- is Empty -> identifier
- is Filled -> details.roomId.value
- }
- }
-}
-
-data class RoomSummaryDetails(
+data class RoomSummary(
val roomId: RoomId,
val name: String?,
val canonicalAlias: RoomAlias?,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt
index e3970619cd..ba44de0ba3 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt
@@ -18,4 +18,5 @@ package io.element.android.libraries.matrix.api.timeline
sealed class TimelineException : Exception() {
data object CannotPaginate : TimelineException()
+ data object EventNotFound : TimelineException()
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
index 84d4d61da5..5ef123f35d 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
@@ -30,6 +30,8 @@ data class TracingFilterConfiguration(
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.INFO,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.INFO,
Target.MATRIX_SDK_UI_TIMELINE to LogLevel.INFO,
+ // To debug OIDC logouts
+ Target.MATRIX_SDK_OIDC to LogLevel.TRACE,
)
fun getLogLevel(target: Target): LogLevel {
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt
index b814bdd5c4..cbc6bacacf 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt
@@ -21,7 +21,9 @@ sealed interface WriteToFilesConfiguration {
data class Enabled(
val directory: String,
val filenamePrefix: String,
- val filenameSuffix: String?,
val numberOfFiles: Int?,
- ) : WriteToFilesConfiguration
+ ) : WriteToFilesConfiguration {
+ // DO NOT CHANGE: suffix *MUST* be "log" for the rageshake server to not rename the file to something generic
+ val filenameSuffix = "log"
+ }
}
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt
new file mode 100644
index 0000000000..68f938adc9
--- /dev/null
+++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.core
+
+import android.net.Uri
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import org.junit.Test
+
+class MatrixPatternsTest {
+ private val longLocalPart = "a".repeat(255 - ":server.com".length - 1)
+
+ @Test
+ fun `findPatterns - returns raw user ids`() {
+ val text = "A @user:server.com and @user2:server.com"
+ val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser())
+ assertThat(patterns).containsExactly(
+ MatrixPatternResult(MatrixPatternType.USER_ID, "@user:server.com", 2, 18),
+ MatrixPatternResult(MatrixPatternType.USER_ID, "@user2:server.com", 23, 40)
+ )
+ }
+
+ @Test
+ fun `findPatterns - returns raw room ids`() {
+ val text = "A !room:server.com and !room2:server.com"
+ val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser())
+ assertThat(patterns).containsExactly(
+ MatrixPatternResult(MatrixPatternType.ROOM_ID, "!room:server.com", 2, 18),
+ MatrixPatternResult(MatrixPatternType.ROOM_ID, "!room2:server.com", 23, 40)
+ )
+ }
+
+ @Test
+ fun `findPatterns - returns raw room aliases`() {
+ val text = "A #room:server.com and #room2:server.com"
+ val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser())
+ assertThat(patterns).containsExactly(
+ MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 18),
+ MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room2:server.com", 23, 40)
+ )
+ }
+
+ @Test
+ fun `findPatterns - returns raw event ids`() {
+ val text = "A \$event:server.com and \$event2:server.com"
+ val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser())
+ assertThat(patterns).containsExactly(
+ MatrixPatternResult(MatrixPatternType.EVENT_ID, "\$event:server.com", 2, 19),
+ MatrixPatternResult(MatrixPatternType.EVENT_ID, "\$event2:server.com", 24, 42)
+ )
+ }
+
+ @Test
+ fun `findPatterns - returns @room mention`() {
+ val text = "A @room mention"
+ val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser())
+ assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.AT_ROOM, "@room", 2, 7))
+ }
+
+ @Test
+ fun `findPatterns - returns user ids in permalinks`() {
+ val text = "A [User](https://matrix.to/#/@user:server.com)"
+ val permalinkParser = aPermalinkParser { _ ->
+ PermalinkData.UserLink(UserId("@user:server.com"))
+ }
+ val patterns = MatrixPatterns.findPatterns(text, permalinkParser)
+ assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.USER_ID, "@user:server.com", 2, 46))
+ }
+
+ @Test
+ fun `findPatterns - returns room aliases in permalinks`() {
+ val text = "A [Room](https://matrix.to/#/#room:server.com)"
+ val permalinkParser = aPermalinkParser { _ ->
+ PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com")))
+ }
+ val patterns = MatrixPatterns.findPatterns(text, permalinkParser)
+ assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 46))
+ }
+
+ @Test
+ fun `test isRoomId`() {
+ assertThat(MatrixPatterns.isRoomId(null)).isFalse()
+ assertThat(MatrixPatterns.isRoomId("")).isFalse()
+ assertThat(MatrixPatterns.isRoomId("not a room id")).isFalse()
+ assertThat(MatrixPatterns.isRoomId(" !room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomId("!room:server.com ")).isFalse()
+ assertThat(MatrixPatterns.isRoomId("@room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomId("#room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomId("\$room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomId("!${longLocalPart}a:server.com")).isFalse()
+
+ assertThat(MatrixPatterns.isRoomId("!room:server.com")).isTrue()
+ assertThat(MatrixPatterns.isRoomId("!$longLocalPart:server.com")).isTrue()
+ assertThat(MatrixPatterns.isRoomId("!#test/room\nversion 11, with @🐈️:maunium.net")).isTrue()
+ }
+
+ @Test
+ fun `test isRoomAlias`() {
+ assertThat(MatrixPatterns.isRoomAlias(null)).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias("")).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias("not a room alias")).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias(" #room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias("#room:server.com ")).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias("@room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias("!room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias("\$room:server.com")).isFalse()
+ assertThat(MatrixPatterns.isRoomAlias("#${longLocalPart}a:server.com")).isFalse()
+
+ assertThat(MatrixPatterns.isRoomAlias("#room:server.com")).isTrue()
+ assertThat(MatrixPatterns.isRoomAlias("#nico's-stickers:neko.dev")).isTrue()
+ assertThat(MatrixPatterns.isRoomAlias("#$longLocalPart:server.com")).isTrue()
+ }
+
+ @Test
+ fun `test isEventId`() {
+ assertThat(MatrixPatterns.isEventId(null)).isFalse()
+ assertThat(MatrixPatterns.isEventId("")).isFalse()
+ assertThat(MatrixPatterns.isEventId("not an event id")).isFalse()
+ assertThat(MatrixPatterns.isEventId(" \$event:server.com")).isFalse()
+ assertThat(MatrixPatterns.isEventId("\$event:server.com ")).isFalse()
+ assertThat(MatrixPatterns.isEventId("@event:server.com")).isFalse()
+ assertThat(MatrixPatterns.isEventId("!event:server.com")).isFalse()
+ assertThat(MatrixPatterns.isEventId("#event:server.com")).isFalse()
+ assertThat(MatrixPatterns.isEventId("$${longLocalPart}a:server.com")).isFalse()
+ assertThat(MatrixPatterns.isEventId("\$" + "a".repeat(255))).isFalse()
+
+ assertThat(MatrixPatterns.isEventId("\$event:server.com")).isTrue()
+ assertThat(MatrixPatterns.isEventId("$$longLocalPart:server.com")).isTrue()
+ assertThat(MatrixPatterns.isEventId("\$9BozuV4TBw6rfRW3rMEgZ5v-jNk1D6FA8Hd1OsWqT9k")).isTrue()
+ assertThat(MatrixPatterns.isEventId("\$" + "a".repeat(254))).isTrue()
+ }
+
+ @Test
+ fun `test isUserId`() {
+ assertThat(MatrixPatterns.isUserId(null)).isFalse()
+ assertThat(MatrixPatterns.isUserId("")).isFalse()
+ assertThat(MatrixPatterns.isUserId("not a user id")).isFalse()
+ assertThat(MatrixPatterns.isUserId(" @user:server.com")).isFalse()
+ assertThat(MatrixPatterns.isUserId("@user:server.com ")).isFalse()
+ assertThat(MatrixPatterns.isUserId("!user:server.com")).isFalse()
+ assertThat(MatrixPatterns.isUserId("#user:server.com")).isFalse()
+ assertThat(MatrixPatterns.isUserId("\$user:server.com")).isFalse()
+ assertThat(MatrixPatterns.isUserId("@${longLocalPart}a:server.com")).isFalse()
+
+ assertThat(MatrixPatterns.isUserId("@user:server.com")).isTrue()
+ assertThat(MatrixPatterns.isUserId("@:server.com")).isTrue()
+ assertThat(MatrixPatterns.isUserId("@$longLocalPart:server.com")).isTrue()
+ }
+
+ private fun aPermalinkParser(block: (String) -> PermalinkData = { PermalinkData.FallbackLink(Uri.EMPTY) }) = object : PermalinkParser {
+ override fun parse(uriString: String): PermalinkData {
+ return block(uriString)
+ }
+ }
+}
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheckTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheckTest.kt
new file mode 100644
index 0000000000..95b13e95af
--- /dev/null
+++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheckTest.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class RoomIsDmCheckTest {
+ @Test
+ fun `a room is a DM only if it has at most 2 members and is direct`() {
+ val isDirect = true
+ val activeMembersCount = 2
+
+ val isDm = isDm(isDirect, activeMembersCount)
+
+ assertThat(isDm).isTrue()
+ }
+
+ @Test
+ fun `a room can be a DM if it has also a single active user`() {
+ val isDirect = true
+ val activeMembersCount = 1
+
+ val isDm = isDm(isDirect, activeMembersCount)
+
+ assertThat(isDm).isTrue()
+ }
+
+ @Test
+ fun `a room is not a DM if it's not direct`() {
+ val isDirect = false
+ val activeMembersCount = 2
+
+ val isDm = isDm(isDirect, activeMembersCount)
+
+ assertThat(isDm).isFalse()
+ }
+
+ @Test
+ fun `a room is not a DM if it has more than 2 active users`() {
+ val isDirect = true
+ val activeMembersCount = 3
+
+ val isDm = isDm(isDirect, activeMembersCount)
+
+ assertThat(isDm).isFalse()
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index c8574a7934..db86e1a2a5 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.impl.notificationsettings.RustNotific
import io.element.android.libraries.matrix.impl.oidc.toRustAction
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
+import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustRoomFactory
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewMapper
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
@@ -64,6 +65,7 @@ import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.util.SessionDirectoryProvider
+import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@@ -131,6 +133,9 @@ class RustMatrixClient(
private val innerRoomListService = syncService.roomListService()
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
+
+ // To make sure only one coroutine affecting the token persistence can run at a time
+ private val tokenRefreshDispatcher = sessionDispatcher.limitedParallelism(1)
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
private val pushersService = RustPushersService(
client = client,
@@ -158,15 +163,20 @@ class RustMatrixClient(
private val isLoggingOut = AtomicBoolean(false)
private val clientDelegate = object : ClientDelegate {
+ private val clientLog get() = Timber.tag(this@RustMatrixClient.toString())
+
override fun didReceiveAuthError(isSoftLogout: Boolean) {
- Timber.w("didReceiveAuthError(isSoftLogout=$isSoftLogout)")
+ clientLog.w("didReceiveAuthError(isSoftLogout=$isSoftLogout)")
if (isLoggingOut.getAndSet(true).not()) {
- Timber.v("didReceiveAuthError -> do the cleanup")
+ clientLog.v("didReceiveAuthError -> do the cleanup")
// TODO handle isSoftLogout parameter.
- appCoroutineScope.launch {
+ appCoroutineScope.launch(tokenRefreshDispatcher) {
val existingData = sessionStore.getSession(client.userId())
- val anonymizedToken = existingData?.accessToken?.takeLast(4)
- Timber.d("Removing session data with token: '...$anonymizedToken'.")
+ val (anonymizedAccessToken, anonymizedRefreshToken) = existingData.anonymizedTokens()
+ clientLog.d(
+ "Removing session data with access token '$anonymizedAccessToken' " +
+ "and refresh token '$anonymizedRefreshToken'."
+ )
if (existingData != null) {
// Set isTokenValid to false
val newData = client.session().toSessionData(
@@ -176,27 +186,30 @@ class RustMatrixClient(
sessionPath = existingData.sessionPath,
)
sessionStore.updateData(newData)
- Timber.d("Removed session data with token: '...$anonymizedToken'.")
+ clientLog.d("Removed session data with access token: '$anonymizedAccessToken'.")
} else {
- Timber.d("No session data found.")
+ clientLog.d("No session data found.")
}
doLogout(doRequest = false, removeSession = false, ignoreSdkError = false)
}.invokeOnCompletion {
if (it != null) {
- Timber.e(it, "Failed to remove session data.")
+ clientLog.e(it, "Failed to remove session data.")
}
}
} else {
- Timber.v("didReceiveAuthError -> already cleaning up")
+ clientLog.v("didReceiveAuthError -> already cleaning up")
}
}
override fun didRefreshTokens() {
- Timber.w("didRefreshTokens()")
- appCoroutineScope.launch {
+ clientLog.w("didRefreshTokens()")
+ appCoroutineScope.launch(tokenRefreshDispatcher) {
val existingData = sessionStore.getSession(client.userId()) ?: return@launch
- val anonymizedToken = client.session().accessToken.takeLast(4)
- Timber.d("Saving new session data with token: '...$anonymizedToken'. Was token valid: ${existingData.isTokenValid}")
+ val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
+ clientLog.d(
+ "Saving new session data with token: access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'. " +
+ "Was token valid: ${existingData.isTokenValid}"
+ )
val newData = client.session().toSessionData(
isTokenValid = true,
loginType = existingData.loginType,
@@ -204,15 +217,17 @@ class RustMatrixClient(
sessionPath = existingData.sessionPath,
)
sessionStore.updateData(newData)
- Timber.d("Saved new session data with token: '...$anonymizedToken'.")
+ clientLog.d("Saved new session data with access token: '$anonymizedAccessToken'.")
}.invokeOnCompletion {
if (it != null) {
- Timber.e(it, "Failed to save new session data.")
+ clientLog.e(it, "Failed to save new session data.")
}
}
}
}
+ private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers)
+
override val roomListService: RoomListService = RustRoomListService(
innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope,
@@ -221,6 +236,7 @@ class RustMatrixClient(
innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope,
),
+ roomSyncSubscriber = roomSyncSubscriber,
)
private val verificationService = RustSessionVerificationService(
@@ -238,6 +254,7 @@ class RustMatrixClient(
dispatchers = dispatchers,
systemClock = clock,
roomContentForwarder = RoomContentForwarder(innerRoomListService),
+ roomSyncSubscriber = roomSyncSubscriber,
isKeyBackupEnabled = { client.encryption().use { it.backupState() == BackupState.ENABLED } },
getSessionData = { sessionStore.getSession(sessionId.value)!! },
)
@@ -311,7 +328,7 @@ class RustMatrixClient(
withTimeout(timeout) {
roomListService.allRooms.summaries
.filter { roomSummaries ->
- roomSummaries.map { it.identifier() }.contains(roomId.value)
+ roomSummaries.map { it.roomId }.contains(roomId)
}
.first()
}
@@ -485,6 +502,9 @@ class RustMatrixClient(
override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
override fun close() {
+ appCoroutineScope.launch {
+ roomFactory.destroy()
+ }
sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy()
notificationSettingsService.destroy()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index 03d8680e4e..7ddf54a300 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -21,6 +21,7 @@ import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
+import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -30,6 +31,7 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.use
+import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -57,6 +59,8 @@ class RustMatrixClientFactory @Inject constructor(
.withUtdHook(utdTracker)
.finish()
+ val (anonymizedAccessToken, anonymizedRefreshToken) = sessionData.anonymizedTokens()
+
RustMatrixClient(
client = client,
syncService = syncService,
@@ -66,7 +70,9 @@ class RustMatrixClientFactory @Inject constructor(
baseDirectory = baseDirectory,
baseCacheDirectory = cacheDirectory,
clock = clock,
- )
+ ).also {
+ Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
+ }
}
internal fun getBaseClientBuilder(
@@ -82,8 +88,6 @@ class RustMatrixClientFactory @Inject constructor(
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
.autoEnableCrossSigning(true)
- // FIXME Quick and dirty fix for stopping version requests on startup https://github.com/matrix-org/matrix-rust-sdk/pull/1376
- .serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5"))
.run {
// Workaround for non-nullable proxy parameter in the SDK, since each call to the ClientBuilder returns a new reference we need to keep
proxyProvider.provides()?.let { proxy(it) } ?: this
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
index 4b7c08f9b4..48f79b5e93 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.services.toolbox.api.systemclock.SystemClock
import org.matrix.rustcomponents.sdk.NotificationEvent
import org.matrix.rustcomponents.sdk.NotificationItem
@@ -40,15 +41,20 @@ class NotificationMapper(
notificationItem: NotificationItem
): NotificationData {
return notificationItem.use { item ->
+ val isDm = isDm(
+ isDirect = item.roomInfo.isDirect,
+ activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
+ )
NotificationData(
eventId = eventId,
roomId = roomId,
senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = item.senderInfo.displayName,
senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous,
- roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { item.roomInfo.isDirect },
+ roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm },
roomDisplayName = item.roomInfo.displayName,
isDirect = item.roomInfo.isDirect,
+ isDm = isDm,
isEncrypted = item.roomInfo.isEncrypted.orFalse(),
isNoisy = item.isNoisy.orFalse(),
timestamp = item.timestamp() ?: clock.epochMillis(),
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
index 3103ff4c1e..30a458b28f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
@@ -19,9 +19,11 @@ package io.element.android.libraries.matrix.impl.permalink
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError
+import org.matrix.rustcomponents.sdk.matrixToRoomAliasPermalink
import org.matrix.rustcomponents.sdk.matrixToUserPermalink
import javax.inject.Inject
@@ -35,4 +37,13 @@ class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
matrixToUserPermalink(userId.value)
}
}
+
+ override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result {
+ if (!MatrixPatterns.isRoomAlias(roomAlias.value)) {
+ return Result.failure(PermalinkBuilderError.InvalidRoomAlias)
+ }
+ return runCatching {
+ matrixToRoomAliasPermalink(roomAlias.value)
+ }
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt
index a65dc17a43..a52d3a87ed 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt
@@ -19,27 +19,30 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RequiredState
-import org.matrix.rustcomponents.sdk.RoomListService
+import org.matrix.rustcomponents.sdk.RoomListServiceInterface
import org.matrix.rustcomponents.sdk.RoomSubscription
import timber.log.Timber
private const val DEFAULT_TIMELINE_LIMIT = 20u
class RoomSyncSubscriber(
- private val roomListService: RoomListService,
+ private val roomListService: RoomListServiceInterface,
private val dispatchers: CoroutineDispatchers,
) {
- private val subscriptionCounts = HashMap()
+ private val subscribedRoomIds = mutableSetOf()
private val mutex = Mutex()
private val settings = RoomSubscription(
requiredState = listOf(
- RequiredState(key = EventType.STATE_ROOM_CANONICAL_ALIAS, value = ""),
+ RequiredState(key = EventType.STATE_ROOM_NAME, value = ""),
RequiredState(key = EventType.STATE_ROOM_TOPIC, value = ""),
+ RequiredState(key = EventType.STATE_ROOM_AVATAR, value = ""),
+ RequiredState(key = EventType.STATE_ROOM_CANONICAL_ALIAS, value = ""),
RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""),
RequiredState(key = EventType.STATE_ROOM_POWER_LEVELS, value = ""),
),
@@ -51,37 +54,38 @@ class RoomSyncSubscriber(
suspend fun subscribe(roomId: RoomId) = mutex.withLock {
withContext(dispatchers.io) {
try {
- val currentSubscription = subscriptionCounts.getOrElse(roomId) { 0 }
- if (currentSubscription == 0) {
- Timber.d("Subscribing to room $roomId}")
- roomListService.room(roomId.value).use { roomListItem ->
- roomListItem.subscribe(settings)
- }
- }
- subscriptionCounts[roomId] = currentSubscription + 1
+ subscribeToRoom(roomId)
} catch (exception: Exception) {
Timber.e("Failed to subscribe to room $roomId")
}
}
}
- suspend fun unsubscribe(roomId: RoomId) = mutex.withLock {
+ suspend fun batchSubscribe(roomIds: List) = mutex.withLock {
withContext(dispatchers.io) {
- try {
- val currentSubscription = subscriptionCounts.getOrElse(roomId) { 0 }
- when (currentSubscription) {
- 0 -> return@withContext
- 1 -> {
- Timber.d("Unsubscribe from room $roomId")
- roomListService.room(roomId.value).use { roomListItem ->
- roomListItem.unsubscribe()
- }
- }
+ for (roomId in roomIds) {
+ try {
+ subscribeToRoom(roomId)
+ } catch (cancellationException: CancellationException) {
+ throw cancellationException
+ } catch (exception: Exception) {
+ Timber.e("Failed to subscribe to room $roomId")
}
- subscriptionCounts[roomId] = currentSubscription - 1
- } catch (exception: Exception) {
- Timber.e("Failed to unsubscribe from room $roomId")
}
}
}
+
+ private fun subscribeToRoom(roomId: RoomId) {
+ if (!isSubscribedTo(roomId)) {
+ Timber.d("Subscribing to room $roomId}")
+ roomListService.room(roomId.value).use { roomListItem ->
+ roomListItem.subscribe(settings)
+ }
+ }
+ subscribedRoomIds.add(roomId)
+ }
+
+ fun isSubscribedTo(roomId: RoomId): Boolean {
+ return subscribedRoomIds.contains(roomId)
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 463defb284..b594bba5e4 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
+import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
@@ -173,8 +174,6 @@ class RustMatrixRoom(
override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId)
- override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId)
-
override suspend fun timelineFocusedOnEvent(eventId: EventId): Result {
return runCatching {
innerRoom.timelineFocusedOnEvent(
@@ -196,8 +195,6 @@ class RustMatrixRoom(
override fun destroy() {
roomCoroutineScope.cancel()
liveTimeline.close()
- innerRoom.destroy()
- roomListItem.destroy()
}
override val displayName: String
@@ -328,6 +325,14 @@ class RustMatrixRoom(
}
}
+ override suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result = withContext(roomDispatcher) {
+ runCatching {
+ MessageEventContent.from(body, htmlBody, mentions).use { newContent ->
+ innerRoom.edit(eventId.value, newContent)
+ }
+ }
+ }
+
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result {
return liveTimeline.sendMessage(body, htmlBody, mentions)
}
@@ -584,7 +589,7 @@ class RustMatrixRoom(
room = innerRoom,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
- return getElementCallRequiredPermissions(sessionId.value)
+ return getElementCallRequiredPermissions(sessionId.value, sessionData.deviceId)
}
},
)
@@ -627,12 +632,13 @@ class RustMatrixRoom(
isLive: Boolean,
onNewSyncedEvent: () -> Unit = {},
): Timeline {
+ val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$timeline")
return RustTimeline(
isKeyBackupEnabled = isKeyBackupEnabled,
isLive = isLive,
matrixRoom = this,
systemClock = systemClock,
- roomCoroutineScope = roomCoroutineScope,
+ coroutineScope = timelineCoroutineScope,
dispatcher = roomDispatcher,
lastLoginTimestamp = sessionData.loginTimestamp,
onNewSyncedEvent = onNewSyncedEvent,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
index 19d9401a27..b559aacf86 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.room
+import androidx.collection.lruCache
import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.RoomId
@@ -30,6 +31,7 @@ import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -41,6 +43,8 @@ import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
+private const val CACHE_SIZE = 16
+
class RustRoomFactory(
private val sessionId: SessionId,
private val notificationSettingsService: NotificationSettingsService,
@@ -50,16 +54,30 @@ class RustRoomFactory(
private val roomContentForwarder: RoomContentForwarder,
private val roomListService: RoomListService,
private val innerRoomListService: InnerRoomListService,
+ private val roomSyncSubscriber: RoomSyncSubscriber,
private val isKeyBackupEnabled: suspend () -> Boolean,
private val getSessionData: suspend () -> SessionData,
) {
@OptIn(ExperimentalCoroutinesApi::class)
- private val createRoomDispatcher = dispatchers.io.limitedParallelism(1)
+ private val dispatcher = dispatchers.io.limitedParallelism(1)
private val mutex = Mutex()
+ private var isDestroyed: Boolean = false
- private val matrixRoomInfoMapper = MatrixRoomInfoMapper()
+ private data class RustRoomReferences(
+ val roomListItem: RoomListItem,
+ val fullRoom: Room,
+ )
- private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers)
+ private val cache = lruCache(
+ maxSize = CACHE_SIZE,
+ onEntryRemoved = { evicted, roomId, oldRoom, _ ->
+ Timber.d("On room removed from cache: $roomId, evicted: $evicted")
+ oldRoom.roomListItem.close()
+ oldRoom.fullRoom.close()
+ }
+ )
+
+ private val matrixRoomInfoMapper = MatrixRoomInfoMapper()
private val eventFilters = TimelineConfig.excludedEvents
.takeIf { it.isNotEmpty() }
@@ -71,30 +89,41 @@ class RustRoomFactory(
)
}
- suspend fun create(roomId: RoomId): MatrixRoom? = withContext(createRoomDispatcher) {
- var cachedPairOfRoom: Pair?
+ suspend fun destroy() {
+ withContext(NonCancellable + dispatcher) {
+ mutex.withLock {
+ Timber.d("Destroying room factory")
+ cache.evictAll()
+ isDestroyed = true
+ }
+ }
+ }
+
+ suspend fun create(roomId: RoomId): MatrixRoom? = withContext(dispatcher) {
mutex.withLock {
- // Check if already in memory...
- cachedPairOfRoom = pairOfRoom(roomId)
- if (cachedPairOfRoom == null) {
+ if (isDestroyed) {
+ Timber.d("Room factory is destroyed, returning null for $roomId")
+ return@withContext null
+ }
+ var roomReferences: RustRoomReferences? = getRoomReferences(roomId)
+ if (roomReferences == null) {
// ... otherwise, lets wait for the SS to load all rooms and check again.
roomListService.allRooms.awaitLoaded()
- cachedPairOfRoom = pairOfRoom(roomId)
+ roomReferences = getRoomReferences(roomId)
}
- }
- if (cachedPairOfRoom == null) {
- Timber.d("No room found for $roomId")
- return@withContext null
- }
- cachedPairOfRoom?.let { (roomListItem, fullRoom) ->
+ if (roomReferences == null) {
+ Timber.d("No room found for $roomId, returning null")
+ return@withContext null
+ }
+ val liveTimeline = roomReferences.fullRoom.timeline()
RustMatrixRoom(
sessionId = sessionId,
isKeyBackupEnabled = isKeyBackupEnabled(),
- roomListItem = roomListItem,
- innerRoom = fullRoom,
- innerTimeline = fullRoom.timeline(),
- notificationSettingsService = notificationSettingsService,
+ roomListItem = roomReferences.roomListItem,
+ innerRoom = roomReferences.fullRoom,
+ innerTimeline = liveTimeline,
sessionCoroutineScope = sessionCoroutineScope,
+ notificationSettingsService = notificationSettingsService,
coroutineDispatchers = dispatchers,
systemClock = systemClock,
roomContentForwarder = roomContentForwarder,
@@ -105,20 +134,28 @@ class RustRoomFactory(
}
}
- private suspend fun pairOfRoom(roomId: RoomId): Pair? {
- val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value)
+ private suspend fun getRoomReferences(roomId: RoomId): RustRoomReferences? {
+ cache[roomId]?.let {
+ Timber.d("Room found in cache for $roomId")
+ return it
+ }
+ val roomListItem = innerRoomListService.roomOrNull(roomId.value)
+ if (roomListItem == null) {
+ Timber.d("Room not found for $roomId")
+ return null
+ }
val fullRoom = try {
- cachedRoomListItem?.fullRoomWithTimeline(filter = eventFilters)
+ roomListItem.fullRoomWithTimeline(filter = eventFilters)
} catch (e: RoomListException) {
Timber.e(e, "Failed to get full room with timeline for $roomId")
- null
+ return null
}
- return if (cachedRoomListItem == null || fullRoom == null) {
- Timber.d("No room cached for $roomId")
- null
- } else {
- Timber.d("Found room cached for $roomId")
- Pair(cachedRoomListItem, fullRoom)
+ Timber.d("Got full room with timeline for $roomId")
+ return RustRoomReferences(
+ roomListItem = roomListItem,
+ fullRoom = fullRoom,
+ ).also {
+ cache.put(roomId, it)
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListEntriesUpdateExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListEntriesUpdateExt.kt
new file mode 100644
index 0000000000..c17b8d63c8
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListEntriesUpdateExt.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomlist
+
+import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
+
+internal fun RoomListEntriesUpdate.describe(): String {
+ return when (this) {
+ is RoomListEntriesUpdate.Set -> {
+ "Set #$index to '${value.displayName()}'"
+ }
+ is RoomListEntriesUpdate.Append -> {
+ "Append ${values.map { "'" + it.displayName() + "'" }}"
+ }
+ is RoomListEntriesUpdate.PushBack -> {
+ "PushBack '${value.displayName()}'"
+ }
+ is RoomListEntriesUpdate.PushFront -> {
+ "PushFront '${value.displayName()}'"
+ }
+ is RoomListEntriesUpdate.Insert -> {
+ "Insert at #$index: '${value.displayName()}'"
+ }
+ is RoomListEntriesUpdate.Remove -> {
+ "Remove #$index"
+ }
+ is RoomListEntriesUpdate.Reset -> {
+ "Reset all to ${values.map { "'" + it.displayName() + "'" }}"
+ }
+ RoomListEntriesUpdate.PopBack -> {
+ "PopBack"
+ }
+ RoomListEntriesUpdate.PopFront -> {
+ "PopFront"
+ }
+ RoomListEntriesUpdate.Clear -> {
+ "Clear"
+ }
+ is RoomListEntriesUpdate.Truncate -> {
+ "Truncate to $length items"
+ }
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
index d987e332c8..df30159c09 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
@@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
-import org.matrix.rustcomponents.sdk.RoomListException
import org.matrix.rustcomponents.sdk.RoomListInterface
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListLoadingState
@@ -76,7 +75,7 @@ internal fun RoomListInterface.entriesFlow(
}
}
val result = entriesWithDynamicAdapters(pageSize.toUInt(), listener)
- val controller = result.controller
+ val controller = result.controller()
controller.setFilter(initialFilterKind)
roomListDynamicEvents.onEach { controllerEvents ->
when (controllerEvents) {
@@ -92,7 +91,8 @@ internal fun RoomListInterface.entriesFlow(
}
}.launchIn(this)
awaitClose {
- result.entriesStream.cancelAndDestroy()
+ result.entriesStream().cancelAndDestroy()
+ controller.destroy()
result.destroy()
}
}.catch {
@@ -127,11 +127,10 @@ internal fun RoomListServiceInterface.syncIndicator(): Flow { _: RoomSummary -> true }
RoomListFilter.None -> { _: RoomSummary -> false }
RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
- roomSummary is RoomSummary.Filled && !roomSummary.details.isDirect && !roomSummary.isInvited()
+ !roomSummary.isDm && !roomSummary.isInvited()
}
RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
- roomSummary is RoomSummary.Filled && roomSummary.details.isDirect && !roomSummary.isInvited()
+ roomSummary.isDm && !roomSummary.isInvited()
}
RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
- roomSummary is RoomSummary.Filled && roomSummary.details.isFavorite && !roomSummary.isInvited()
+ roomSummary.isFavorite && !roomSummary.isInvited()
}
RoomListFilter.Unread -> { roomSummary: RoomSummary ->
- roomSummary is RoomSummary.Filled &&
- !roomSummary.isInvited() &&
- (roomSummary.details.numUnreadNotifications > 0 || roomSummary.details.isMarkedUnread)
+ !roomSummary.isInvited() && (roomSummary.numUnreadNotifications > 0 || roomSummary.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
- roomSummary is RoomSummary.Filled && roomSummary.details.name.orEmpty().contains(pattern, ignoreCase = true)
+ roomSummary.name.orEmpty().contains(pattern, ignoreCase = true)
}
RoomListFilter.Invite -> { roomSummary: RoomSummary ->
roomSummary.isInvited()
@@ -61,4 +59,4 @@ fun List.filter(filter: RoomListFilter): List {
}
}
-private fun RoomSummary.isInvited() = this is RoomSummary.Filled && this.details.currentUserMembership == CurrentUserMembership.INVITED
+private fun RoomSummary.isInvited() = currentUserMembership == CurrentUserMembership.INVITED
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
index f45c843694..a833734a5c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt
@@ -18,7 +18,8 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
+import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
import io.element.android.libraries.matrix.impl.room.elementHeroes
import io.element.android.libraries.matrix.impl.room.map
@@ -28,12 +29,12 @@ import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.use
class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory()) {
- suspend fun create(roomListItem: RoomListItem): RoomSummaryDetails {
+ suspend fun create(roomListItem: RoomListItem): RoomSummary {
val roomInfo = roomListItem.roomInfo()
- val latestRoomMessage = roomListItem.latestEvent()?.use {
- roomMessageFactory.create(it)
+ val latestRoomMessage = roomListItem.latestEvent().use { event ->
+ roomMessageFactory.create(event)
}
- return RoomSummaryDetails(
+ return RoomSummary(
roomId = RoomId(roomInfo.id),
name = roomInfo.displayName,
canonicalAlias = roomInfo.canonicalAlias?.let(::RoomAlias),
@@ -47,7 +48,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
hasRoomCall = roomInfo.hasRoomCall,
- isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
+ isDm = isDm(isDirect = roomInfo.isDirect, activeMembersCount = roomInfo.activeMembersCount.toInt()),
isFavorite = roomInfo.isFavourite,
currentUserMembership = roomInfo.membership.map(),
heroes = roomInfo.elementHeroes(),
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt
index 3cfd01f54c..8b9cfc6151 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt
@@ -22,11 +22,10 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
-import org.matrix.rustcomponents.sdk.RoomListEntry
+import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListServiceInterface
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
-import java.util.UUID
import kotlin.coroutines.CoroutineContext
class RoomSummaryListProcessor(
@@ -50,15 +49,17 @@ class RoomSummaryListProcessor(
suspend fun rebuildRoomSummaries() {
updateRoomSummaries {
forEachIndexed { i, summary ->
- this[i] = when (summary) {
- is RoomSummary.Empty -> summary
- is RoomSummary.Filled -> buildAndCacheRoomSummaryForIdentifier(summary.identifier())
+ val result = buildAndCacheRoomSummaryForIdentifier(summary.roomId.value)
+ if (result != null) {
+ this[i] = result
}
}
}
}
private suspend fun MutableList.applyUpdate(update: RoomListEntriesUpdate) {
+ // Remove this comment to debug changes in the room list
+ // Timber.d("Apply room list update: ${update.describe()}")
when (update) {
is RoomListEntriesUpdate.Append -> {
val roomSummaries = update.values.map {
@@ -104,27 +105,23 @@ class RoomSummaryListProcessor(
}
}
- private suspend fun buildSummaryForRoomListEntry(entry: RoomListEntry): RoomSummary {
- return when (entry) {
- RoomListEntry.Empty -> buildEmptyRoomSummary()
- is RoomListEntry.Filled -> buildAndCacheRoomSummaryForIdentifier(entry.roomId)
- is RoomListEntry.Invalidated -> {
- roomSummariesByIdentifier[entry.roomId] ?: buildAndCacheRoomSummaryForIdentifier(entry.roomId)
- }
- }
+ private suspend fun buildSummaryForRoomListEntry(entry: RoomListItem): RoomSummary {
+ return buildAndCacheRoomSummaryForRoomListItem(entry)
}
- private fun buildEmptyRoomSummary(): RoomSummary {
- return RoomSummary.Empty(UUID.randomUUID().toString())
+ private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary? {
+ val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem ->
+ buildAndCacheRoomSummaryForRoomListItem(roomListItem)
+ }
+ if (builtRoomSummary == null) {
+ roomSummariesByIdentifier.remove(identifier)
+ }
+ return builtRoomSummary
}
- private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary {
- val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem ->
- RoomSummary.Filled(
- details = roomSummaryDetailsFactory.create(roomListItem)
- )
- } ?: buildEmptyRoomSummary()
- roomSummariesByIdentifier[builtRoomSummary.identifier()] = builtRoomSummary
+ private suspend fun buildAndCacheRoomSummaryForRoomListItem(roomListItem: RoomListItem): RoomSummary {
+ val builtRoomSummary = roomSummaryDetailsFactory.create(roomListItem = roomListItem)
+ roomSummariesByIdentifier[builtRoomSummary.roomId.value] = builtRoomSummary
return builtRoomSummary
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
index 8b8ccc3926..7297913097 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
@@ -16,11 +16,13 @@
package io.element.android.libraries.matrix.impl.roomlist
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
+import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@@ -29,10 +31,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
-import org.matrix.rustcomponents.sdk.RoomListException
-import org.matrix.rustcomponents.sdk.RoomListInput
-import org.matrix.rustcomponents.sdk.RoomListRange
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber
@@ -45,6 +43,7 @@ internal class RustRoomListService(
private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
private val roomListFactory: RoomListFactory,
+ private val roomSyncSubscriber: RoomSyncSubscriber,
) : RoomListService {
override fun createRoomList(
pageSize: Int,
@@ -62,6 +61,14 @@ internal class RustRoomListService(
}
}
+ override suspend fun subscribeToVisibleRooms(roomIds: List) {
+ val toSubscribe = roomIds.filterNot { roomSyncSubscriber.isSubscribedTo(it) }
+ if (toSubscribe.isNotEmpty()) {
+ Timber.d("Subscribe to ${toSubscribe.size} rooms: $toSubscribe")
+ roomSyncSubscriber.batchSubscribe(toSubscribe)
+ }
+ }
+
override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE,
coroutineContext = sessionDispatcher,
@@ -73,20 +80,6 @@ internal class RustRoomListService(
allRooms.loadAllIncrementally(sessionCoroutineScope)
}
- override fun updateAllRoomsVisibleRange(range: IntRange) {
- Timber.v("setVisibleRange=$range")
- sessionCoroutineScope.launch {
- try {
- val ranges = listOf(RoomListRange(range.first.toUInt(), range.last.toUInt()))
- innerRoomListService.applyInput(
- RoomListInput.Viewport(ranges)
- )
- } catch (exception: RoomListException) {
- Timber.e(exception, "Failed updating visible range")
- }
- }
- }
-
override val syncIndicator: StateFlow =
innerRoomListService.syncIndicator()
.map { it.toSyncIndicator() }
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
index c8cb4eef1c..d38c86f0f9 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
@@ -26,7 +26,7 @@ import org.matrix.rustcomponents.sdk.TimelineItem
class MatrixTimelineItemMapper(
private val fetchDetailsForEvent: suspend (EventId) -> Result,
- private val roomCoroutineScope: CoroutineScope,
+ private val coroutineScope: CoroutineScope,
private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(),
private val eventTimelineItemMapper: EventTimelineItemMapper = EventTimelineItemMapper(),
) {
@@ -49,7 +49,7 @@ class MatrixTimelineItemMapper(
return MatrixTimelineItem.Other
}
- private fun fetchEventDetails(eventId: EventId) = roomCoroutineScope.launch {
+ private fun fetchEventDetails(eventId: EventId) = coroutineScope.launch {
fetchDetailsForEvent(eventId)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt
index dc012f67b3..2adcab5491 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt
@@ -25,13 +25,13 @@ import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import org.matrix.rustcomponents.sdk.PaginationStatusListener
-import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
+import org.matrix.rustcomponents.sdk.TimelineInterface
import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
-internal fun Timeline.liveBackPaginationStatus(): Flow = callbackFlow {
+internal fun TimelineInterface.liveBackPaginationStatus(): Flow = callbackFlow {
val listener = object : PaginationStatusListener {
override fun onUpdate(status: LiveBackPaginationStatus) {
trySend(status)
@@ -45,7 +45,7 @@ internal fun Timeline.liveBackPaginationStatus(): Flow
Timber.d(it, "liveBackPaginationStatus() failed")
}.buffer(Channel.UNLIMITED)
-internal fun Timeline.timelineDiffFlow(): Flow> =
+internal fun TimelineInterface.timelineDiffFlow(): Flow> =
callbackFlow {
val listener = object : TimelineListener {
override fun onUpdate(diff: List) {
@@ -62,7 +62,7 @@ internal fun Timeline.timelineDiffFlow(): Flow> =
Timber.d(it, "timelineDiffFlow() failed")
}.buffer(Channel.UNLIMITED)
-internal suspend fun Timeline.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
+internal suspend fun TimelineInterface.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
val result = addListener(NoOpTimelineListener)
try {
action()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index 443191a538..b57efc5603 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention
+import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@@ -41,7 +42,6 @@ import io.element.android.libraries.matrix.impl.media.toMSC3246range
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.location.toInner
-import io.element.android.libraries.matrix.impl.room.map
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
@@ -50,13 +50,13 @@ import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIn
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper
+import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -65,75 +65,76 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat
-import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
-import org.matrix.rustcomponents.sdk.TimelineChange
-import org.matrix.rustcomponents.sdk.TimelineDiff
-import org.matrix.rustcomponents.sdk.TimelineItem
-import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
-import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
-import uniffi.matrix_sdk_ui.EventItemOrigin
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
import java.io.File
import java.util.Date
-import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
-private const val INITIAL_MAX_SIZE = 50
private const val PAGINATION_SIZE = 50
class RustTimeline(
private val inner: InnerTimeline,
- isLive: Boolean,
+ private val isLive: Boolean,
systemClock: SystemClock,
- roomCoroutineScope: CoroutineScope,
isKeyBackupEnabled: Boolean,
private val matrixRoom: MatrixRoom,
+ private val coroutineScope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
lastLoginTimestamp: Date?,
private val roomContentForwarder: RoomContentForwarder,
- private val onNewSyncedEvent: () -> Unit,
+ onNewSyncedEvent: () -> Unit,
) : Timeline {
private val initLatch = CompletableDeferred()
- private val isInit = AtomicBoolean(false)
+ private val isInit = MutableStateFlow(false)
private val _timelineItems: MutableStateFlow> =
MutableStateFlow(emptyList())
- private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
- lastLoginTimestamp = lastLoginTimestamp,
- isRoomEncrypted = matrixRoom.isEncrypted,
- isKeyBackupEnabled = isKeyBackupEnabled,
- dispatcher = dispatcher,
- )
-
- private val roomBeginningPostProcessor = RoomBeginningPostProcessor()
- private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
- private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(isLive)
-
private val timelineEventContentMapper = TimelineEventContentMapper()
private val inReplyToMapper = InReplyToMapper(timelineEventContentMapper)
- private val timelineItemFactory = MatrixTimelineItemMapper(
+ private val timelineItemMapper = MatrixTimelineItemMapper(
fetchDetailsForEvent = this::fetchDetailsForEvent,
- roomCoroutineScope = roomCoroutineScope,
+ coroutineScope = coroutineScope,
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
eventTimelineItemMapper = EventTimelineItemMapper(
contentMapper = timelineEventContentMapper
)
)
-
private val timelineDiffProcessor = MatrixTimelineDiffProcessor(
timelineItems = _timelineItems,
- timelineItemFactory = timelineItemFactory,
+ timelineItemFactory = timelineItemMapper,
+ )
+ private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
+ lastLoginTimestamp = lastLoginTimestamp,
+ isRoomEncrypted = matrixRoom.isEncrypted,
+ isKeyBackupEnabled = isKeyBackupEnabled,
+ dispatcher = dispatcher,
+ )
+ private val timelineItemsSubscriber = TimelineItemsSubscriber(
+ timeline = inner,
+ timelineCoroutineScope = coroutineScope,
+ timelineDiffProcessor = timelineDiffProcessor,
+ initLatch = initLatch,
+ isInit = isInit,
+ dispatcher = dispatcher,
+ onNewSyncedEvent = onNewSyncedEvent,
)
+ private val roomBeginningPostProcessor = RoomBeginningPostProcessor()
+ private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
+ private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(isLive)
+
private val backPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)
)
@@ -143,35 +144,25 @@ class RustTimeline(
)
init {
- roomCoroutineScope.launch(dispatcher) {
- inner.timelineDiffFlow()
- .onEach { diffs ->
- if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
- onNewSyncedEvent()
- }
- postDiffs(diffs)
- }
- .launchIn(this)
-
- launch {
- fetchMembers()
- }
+ coroutineScope.fetchMembers()
+ if (isLive) {
+ // When timeline is live, we need to listen to the back pagination status as
+ // sdk can automatically paginate backwards.
+ coroutineScope.registerBackPaginationStatusListener()
+ }
+ }
- if (isLive) {
- // When timeline is live, we need to listen to the back pagination status as
- // sdk can automatically paginate backwards.
- inner.liveBackPaginationStatus()
- .onEach { backPaginationStatus ->
- updatePaginationStatus(Timeline.PaginationDirection.BACKWARDS) {
- when (backPaginationStatus) {
- is LiveBackPaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitStartOfTimeline)
- is LiveBackPaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true)
- }
- }
+ private fun CoroutineScope.registerBackPaginationStatusListener() {
+ inner.liveBackPaginationStatus()
+ .onEach { backPaginationStatus ->
+ updatePaginationStatus(Timeline.PaginationDirection.BACKWARDS) {
+ when (backPaginationStatus) {
+ is LiveBackPaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitStartOfTimeline)
+ is LiveBackPaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true)
}
- .launchIn(this)
+ }
}
- }
+ .launchIn(this)
}
override val membershipChangeEventReceived: Flow = timelineDiffProcessor.membershipChangeEventReceived
@@ -214,7 +205,7 @@ class RustTimeline(
}
private fun canPaginate(direction: Timeline.PaginationDirection): Boolean {
- if (!isInit.get()) return false
+ if (!isInit.value) return false
return when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate
@@ -232,28 +223,38 @@ class RustTimeline(
_timelineItems,
backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(),
- ) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward ->
+ isInit,
+ ) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward, isInit ->
withContext(dispatcher) {
timelineItems
- .let { items -> encryptedHistoryPostProcessor.process(items) }
- .let { items ->
+ .process { items -> encryptedHistoryPostProcessor.process(items) }
+ .process { items ->
roomBeginningPostProcessor.process(
items = items,
isDm = matrixRoom.isDm,
hasMoreToLoadBackwards = hasMoreToLoadBackward
)
}
- .let { items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward) }
+ .process(predicate = isInit) { items ->
+ loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward)
+ }
// Keep lastForwardIndicatorsPostProcessor last
- .let { items -> lastForwardIndicatorsPostProcessor.process(items) }
+ .process(predicate = isInit) { items ->
+ lastForwardIndicatorsPostProcessor.process(items)
+ }
}
+ }.onStart {
+ timelineItemsSubscriber.subscribeIfNeeded()
+ }.onCompletion {
+ timelineItemsSubscriber.unsubscribeIfNeeded()
}
override fun close() {
+ coroutineScope.cancel()
inner.close()
}
- private suspend fun fetchMembers() = withContext(dispatcher) {
+ private fun CoroutineScope.fetchMembers() = launch(dispatcher) {
initLatch.await()
try {
inner.fetchMembers()
@@ -262,34 +263,8 @@ class RustTimeline(
}
}
- private suspend fun postItems(items: List) = coroutineScope {
- // Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
- items.chunked(INITIAL_MAX_SIZE).reversed().forEach {
- ensureActive()
- timelineDiffProcessor.postItems(it)
- }
- isInit.set(true)
- initLatch.complete(Unit)
- }
-
- private suspend fun postDiffs(diffs: List) {
- val diffsToProcess = diffs.toMutableList()
- if (!isInit.get()) {
- val resetDiff = diffsToProcess.firstOrNull { it.change() == TimelineChange.RESET }
- if (resetDiff != null) {
- // Keep using the postItems logic so we can post the timelineItems asap.
- postItems(resetDiff.reset() ?: emptyList())
- diffsToProcess.remove(resetDiff)
- }
- }
- initLatch.await()
- if (diffsToProcess.isNotEmpty()) {
- timelineDiffProcessor.postDiffs(diffsToProcess)
- }
- }
-
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List): Result = withContext(dispatcher) {
- messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()).use { content ->
+ MessageEventContent.from(body, htmlBody, mentions).use { content ->
runCatching {
inner.send(content)
}
@@ -298,20 +273,8 @@ class RustTimeline(
override suspend fun redactEvent(eventId: EventId?, transactionId: TransactionId?, reason: String?): Result = withContext(dispatcher) {
runCatching {
- when {
- eventId != null -> {
- inner.getEventTimelineItemByEventId(eventId.value).use {
- inner.redactEvent(item = it, reason = reason)
- }
- }
- transactionId != null -> {
- inner.getEventTimelineItemByTransactionId(transactionId.value).use {
- inner.redactEvent(item = it, reason = reason)
- }
- }
- else -> {
- error("Either eventId or transactionId must be non-null")
- }
+ getEventTimelineItem(eventId, transactionId).use { item ->
+ inner.redactEvent(item = item, reason = reason)
}
}
}
@@ -325,22 +288,11 @@ class RustTimeline(
): Result =
withContext(dispatcher) {
runCatching {
- when {
- originalEventId != null -> {
- inner.editByEventId(
- newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
- eventId = originalEventId.value,
- )
- }
- transactionId != null -> {
- inner.edit(
- newContent = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()),
- item = inner.getEventTimelineItemByTransactionId(transactionId.value),
- )
- }
- else -> {
- error("Either originalEventId or transactionId must be non null")
- }
+ getEventTimelineItem(originalEventId, transactionId).use { item ->
+ inner.edit(
+ newContent = MessageEventContent.from(body, htmlBody, mentions),
+ item = item,
+ )
}
}
}
@@ -353,7 +305,7 @@ class RustTimeline(
fromNotification: Boolean,
): Result = withContext(dispatcher) {
runCatching {
- val msg = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map())
+ val msg = MessageEventContent.from(body, htmlBody, mentions)
inner.sendReply(msg, eventId.value)
}
}
@@ -380,6 +332,20 @@ class RustTimeline(
}
}
+ @Throws
+ private suspend fun getEventTimelineItem(eventId: EventId?, transactionId: TransactionId?): EventTimelineItem {
+ return try {
+ when {
+ eventId != null -> inner.getEventTimelineItemByEventId(eventId.value)
+ transactionId != null -> inner.getEventTimelineItemByTransactionId(transactionId.value)
+ else -> error("Either eventId or transactionId must be non-null")
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to get event timeline item")
+ throw TimelineException.EventNotFound
+ }
+ }
+
override suspend fun sendVideo(
file: File,
thumbnailFile: File?,
@@ -536,25 +502,12 @@ class RustTimeline(
)
}
- private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
- if (htmlBody != null) {
- messageEventContentFromHtml(body, htmlBody)
- } else {
- messageEventContentFromMarkdown(body)
- }
-
private fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result {
return runCatching {
MediaUploadHandlerImpl(files, handle())
}
}
- private suspend fun fetchDetailsForEvent(eventId: EventId): Result {
- return runCatching {
- inner.fetchDetailsForEvent(eventId.value)
- }
- }
-
override suspend fun loadReplyDetails(eventId: EventId): InReplyTo = withContext(dispatcher) {
val timelineItem = _timelineItems.value.firstOrNull { timelineItem ->
timelineItem is MatrixTimelineItem.Event && timelineItem.eventId == eventId
@@ -571,4 +524,21 @@ class RustTimeline(
inner.loadReplyDetails(eventId.value).use(inReplyToMapper::map)
}
}
+
+ private suspend fun fetchDetailsForEvent(eventId: EventId): Result {
+ return runCatching {
+ inner.fetchDetailsForEvent(eventId.value)
+ }
+ }
+}
+
+private suspend fun List.process(
+ predicate: Boolean = true,
+ processor: suspend (List) -> List
+): List {
+ return if (predicate) {
+ processor(this)
+ } else {
+ this
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt
new file mode 100644
index 0000000000..d5454ff254
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.timeline
+
+import io.element.android.libraries.core.coroutine.childScope
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.matrix.rustcomponents.sdk.Timeline
+import org.matrix.rustcomponents.sdk.TimelineChange
+import org.matrix.rustcomponents.sdk.TimelineDiff
+import org.matrix.rustcomponents.sdk.TimelineItem
+import uniffi.matrix_sdk_ui.EventItemOrigin
+
+private const val INITIAL_MAX_SIZE = 50
+
+/**
+ * This class is responsible for subscribing to a timeline and post the items/diffs to the timelineDiffProcessor.
+ * It will also trigger a callback when a new synced event is received.
+ * It will also handle the initial items and make sure they are posted before any diff.
+ */
+internal class TimelineItemsSubscriber(
+ timelineCoroutineScope: CoroutineScope,
+ dispatcher: CoroutineDispatcher,
+ private val timeline: Timeline,
+ private val timelineDiffProcessor: MatrixTimelineDiffProcessor,
+ private val initLatch: CompletableDeferred,
+ private val isInit: MutableStateFlow,
+ private val onNewSyncedEvent: () -> Unit,
+) {
+ private var subscriptionCount = 0
+ private val mutex = Mutex()
+
+ private val coroutineScope = timelineCoroutineScope.childScope(dispatcher, "TimelineItemsSubscriber")
+
+ /**
+ * Add a subscription to the timeline and start posting items/diffs to the timelineDiffProcessor.
+ * It will also trigger a callback when a new synced event is received.
+ */
+ suspend fun subscribeIfNeeded() = mutex.withLock {
+ if (subscriptionCount == 0) {
+ timeline.timelineDiffFlow()
+ .onEach { diffs ->
+ if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
+ onNewSyncedEvent()
+ }
+ postDiffs(diffs)
+ }
+ .launchIn(coroutineScope)
+ }
+ subscriptionCount++
+ }
+
+ /**
+ * Remove a subscription to the timeline and unsubscribe if needed.
+ * The timeline will be unsubscribed when the last subscription is removed.
+ * If the timelineCoroutineScope is cancelled, the timeline will be unsubscribed automatically.
+ */
+ suspend fun unsubscribeIfNeeded() = mutex.withLock {
+ when (subscriptionCount) {
+ 0 -> return@withLock
+ 1 -> {
+ coroutineScope.coroutineContext.cancelChildren()
+ }
+ }
+ subscriptionCount--
+ }
+
+ private suspend fun postItems(items: List) = coroutineScope {
+ // Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
+ items.chunked(INITIAL_MAX_SIZE).reversed().forEach {
+ ensureActive()
+ timelineDiffProcessor.postItems(it)
+ }
+ isInit.value = true
+ initLatch.complete(Unit)
+ }
+
+ private suspend fun postDiffs(diffs: List) {
+ val diffsToProcess = diffs.toMutableList()
+ if (!isInit.value) {
+ val resetDiff = diffsToProcess.firstOrNull { it.change() == TimelineChange.RESET }
+ if (resetDiff != null) {
+ // Keep using the postItems logic so we can post the timelineItems asap.
+ postItems(resetDiff.reset() ?: emptyList())
+ diffsToProcess.remove(resetDiff)
+ }
+ }
+ initLatch.await()
+ if (diffsToProcess.isNotEmpty()) {
+ timelineDiffProcessor.postDiffs(diffsToProcess)
+ }
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
new file mode 100644
index 0000000000..e1728bb528
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.util
+
+import io.element.android.libraries.matrix.api.room.Mention
+import io.element.android.libraries.matrix.impl.room.map
+import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
+import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
+import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
+
+/**
+ * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
+ */
+object MessageEventContent {
+ fun from(body: String, htmlBody: String?, mentions: List): RoomMessageEventContentWithoutRelation {
+ return if (htmlBody != null) {
+ messageEventContentFromHtml(body, htmlBody)
+ } else {
+ messageEventContentFromMarkdown(body)
+ }.withMentions(mentions.map())
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt
new file mode 100644
index 0000000000..ea07dc16c7
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.util
+
+import io.element.android.libraries.sessionstorage.api.SessionData
+import org.matrix.rustcomponents.sdk.Session
+import java.security.MessageDigest
+
+private val sha256 by lazy { MessageDigest.getInstance("SHA-256") }
+
+@OptIn(ExperimentalStdlibApi::class)
+private fun anonymizeToken(token: String): String {
+ return sha256.digest(token.toByteArray()).toHexString()
+}
+
+fun SessionData?.anonymizedTokens(): Pair {
+ if (this == null) return null to null
+ return anonymizeToken(accessToken) to refreshToken?.let { anonymizeToken(it) }
+}
+
+fun Session?.anonymizedTokens(): Pair {
+ if (this == null) return null to null
+ return anonymizeToken(accessToken) to refreshToken?.let { anonymizeToken(it) }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt
index 02fb9cd24b..beef6f4b2b 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt
@@ -19,51 +19,36 @@ package io.element.android.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
-import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
-import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
+import io.element.android.libraries.matrix.test.room.aRoomSummary
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListFilterTest {
- private val regularRoom = aRoomSummaryFilled(
- aRoomSummaryDetails(
- isDirect = false
- )
+ private val regularRoom = aRoomSummary(
+ isDm = false
)
- private val directRoom = aRoomSummaryFilled(
- aRoomSummaryDetails(
- isDirect = true
- )
+ private val dmRoom = aRoomSummary(
+ isDm = true
)
- private val favoriteRoom = aRoomSummaryFilled(
- aRoomSummaryDetails(
- isFavorite = true
- )
+ private val favoriteRoom = aRoomSummary(
+ isFavorite = true
)
- private val markedAsUnreadRoom = aRoomSummaryFilled(
- aRoomSummaryDetails(
- isMarkedUnread = true
- )
+ private val markedAsUnreadRoom = aRoomSummary(
+ isMarkedUnread = true
)
- private val unreadNotificationRoom = aRoomSummaryFilled(
- aRoomSummaryDetails(
- numUnreadNotifications = 1
- )
+ private val unreadNotificationRoom = aRoomSummary(
+ numUnreadNotifications = 1
)
- private val roomToSearch = aRoomSummaryFilled(
- aRoomSummaryDetails(
- name = "Room to search"
- )
+ private val roomToSearch = aRoomSummary(
+ name = "Room to search"
)
- private val invitedRoom = aRoomSummaryFilled(
- aRoomSummaryDetails(
- currentUserMembership = CurrentUserMembership.INVITED
- )
+ private val invitedRoom = aRoomSummary(
+ currentUserMembership = CurrentUserMembership.INVITED
)
private val roomSummaries = listOf(
regularRoom,
- directRoom,
+ dmRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
@@ -86,7 +71,7 @@ class RoomListFilterTest {
@Test
fun `Room list filter people`() = runTest {
val filter = RoomListFilter.Category.People
- assertThat(roomSummaries.filter(filter)).containsExactly(directRoom)
+ assertThat(roomSummaries.filter(filter)).containsExactly(dmRoom)
}
@Test
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt
index 6f42553ced..f2e2f07ad6 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt
@@ -18,23 +18,32 @@ package io.element.android.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import com.sun.jna.Pointer
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
+import org.matrix.rustcomponents.sdk.EventTimelineItem
+import org.matrix.rustcomponents.sdk.Membership
+import org.matrix.rustcomponents.sdk.NoPointer
+import org.matrix.rustcomponents.sdk.RoomHero
+import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomList
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
-import org.matrix.rustcomponents.sdk.RoomListEntry
-import org.matrix.rustcomponents.sdk.RoomListInput
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListServiceInterface
import org.matrix.rustcomponents.sdk.RoomListServiceStateListener
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicatorListener
+import org.matrix.rustcomponents.sdk.RoomMember
+import org.matrix.rustcomponents.sdk.RoomNotificationMode
+import org.matrix.rustcomponents.sdk.RoomSubscription
import org.matrix.rustcomponents.sdk.TaskHandle
// NOTE: this class is using a fake implementation of a Rust SDK interface which returns actual Rust objects with pointers.
@@ -44,33 +53,34 @@ class RoomSummaryListProcessorTest {
@Test
fun `Append adds new entries at the end of the list`() = runTest {
- summaries.value = listOf(aRoomSummaryFilled())
+ summaries.value = listOf(aRoomSummary())
val processor = createProcessor()
- processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(RoomListEntry.Empty, RoomListEntry.Empty, RoomListEntry.Empty))))
+ val newEntry = FakeRoomListItem(A_ROOM_ID_2)
+ processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(newEntry, newEntry, newEntry))))
assertThat(summaries.value.count()).isEqualTo(4)
- assertThat(summaries.value.subList(1, 4).all { it is RoomSummary.Empty }).isTrue()
+ assertThat(summaries.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue()
}
@Test
fun `PushBack adds a new entry at the end of the list`() = runTest {
summaries.value = listOf(aRoomSummaryFilled())
val processor = createProcessor()
- processor.postUpdate(listOf(RoomListEntriesUpdate.PushBack(RoomListEntry.Empty)))
+ processor.postUpdate(listOf(RoomListEntriesUpdate.PushBack(FakeRoomListItem(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(2)
- assertThat(summaries.value.last()).isInstanceOf(RoomSummary.Empty::class.java)
+ assertThat(summaries.value.last().roomId).isEqualTo(A_ROOM_ID_2)
}
@Test
fun `PushFront inserts a new entry at the start of the list`() = runTest {
summaries.value = listOf(aRoomSummaryFilled())
val processor = createProcessor()
- processor.postUpdate(listOf(RoomListEntriesUpdate.PushFront(RoomListEntry.Empty)))
+ processor.postUpdate(listOf(RoomListEntriesUpdate.PushFront(FakeRoomListItem(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(2)
- assertThat(summaries.value.first()).isInstanceOf(RoomSummary.Empty::class.java)
+ assertThat(summaries.value.first().roomId).isEqualTo(A_ROOM_ID_2)
}
@Test
@@ -79,10 +89,10 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor()
val index = 0
- processor.postUpdate(listOf(RoomListEntriesUpdate.Set(index.toUInt(), RoomListEntry.Empty)))
+ processor.postUpdate(listOf(RoomListEntriesUpdate.Set(index.toUInt(), FakeRoomListItem(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(1)
- assertThat(summaries.value[index]).isInstanceOf(RoomSummary.Empty::class.java)
+ assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2)
}
@Test
@@ -91,10 +101,10 @@ class RoomSummaryListProcessorTest {
val processor = createProcessor()
val index = 0
- processor.postUpdate(listOf(RoomListEntriesUpdate.Insert(index.toUInt(), RoomListEntry.Empty)))
+ processor.postUpdate(listOf(RoomListEntriesUpdate.Insert(index.toUInt(), FakeRoomListItem(A_ROOM_ID_2))))
assertThat(summaries.value.count()).isEqualTo(2)
- assertThat(summaries.value[index]).isInstanceOf(RoomSummary.Empty::class.java)
+ assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2)
}
@Test
@@ -106,7 +116,7 @@ class RoomSummaryListProcessorTest {
processor.postUpdate(listOf(RoomListEntriesUpdate.Remove(index.toUInt())))
assertThat(summaries.value.count()).isEqualTo(1)
- assertThat((summaries.value[index] as RoomSummary.Filled).identifier()).isEqualTo(A_ROOM_ID_2.value)
+ assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2)
}
@Test
@@ -118,7 +128,7 @@ class RoomSummaryListProcessorTest {
processor.postUpdate(listOf(RoomListEntriesUpdate.PopBack))
assertThat(summaries.value.count()).isEqualTo(1)
- assertThat((summaries.value[index] as RoomSummary.Filled).identifier()).isEqualTo(A_ROOM_ID.value)
+ assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID)
}
@Test
@@ -130,7 +140,7 @@ class RoomSummaryListProcessorTest {
processor.postUpdate(listOf(RoomListEntriesUpdate.PopFront))
assertThat(summaries.value.count()).isEqualTo(1)
- assertThat((summaries.value[index] as RoomSummary.Filled).identifier()).isEqualTo(A_ROOM_ID_2.value)
+ assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2)
}
@Test
@@ -152,7 +162,7 @@ class RoomSummaryListProcessorTest {
processor.postUpdate(listOf(RoomListEntriesUpdate.Truncate(1u)))
assertThat(summaries.value.count()).isEqualTo(1)
- assertThat((summaries.value[index] as RoomSummary.Filled).identifier()).isEqualTo(A_ROOM_ID.value)
+ assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID)
}
private fun TestScope.createProcessor() = RoomSummaryListProcessor(
@@ -168,8 +178,6 @@ class RoomSummaryListProcessorTest {
return RoomList(Pointer.NULL)
}
- override suspend fun applyInput(input: RoomListInput) = Unit
-
override fun room(roomId: String): RoomListItem {
return RoomListItem(Pointer.NULL)
}
@@ -183,3 +191,83 @@ class RoomSummaryListProcessorTest {
}
}
}
+
+private fun aRustRoomInfo(
+ id: String = A_ROOM_ID.value,
+ displayName: String = A_ROOM_NAME,
+ rawName: String = A_ROOM_NAME,
+ topic: String? = null,
+ avatarUrl: String? = null,
+ isDirect: Boolean = false,
+ isPublic: Boolean = false,
+ isSpace: Boolean = false,
+ isTombstoned: Boolean = false,
+ isFavourite: Boolean = false,
+ canonicalAlias: String? = null,
+ alternativeAliases: List = listOf(),
+ membership: Membership = Membership.JOINED,
+ inviter: RoomMember? = null,
+ heroes: List = listOf(),
+ activeMembersCount: ULong = 0uL,
+ invitedMembersCount: ULong = 0uL,
+ joinedMembersCount: ULong = 0uL,
+ userPowerLevels: Map = mapOf(),
+ highlightCount: ULong = 0uL,
+ notificationCount: ULong = 0uL,
+ userDefinedNotificationMode: RoomNotificationMode? = null,
+ hasRoomCall: Boolean = false,
+ activeRoomCallParticipants: List = listOf(),
+ isMarkedUnread: Boolean = false,
+ numUnreadMessages: ULong = 0uL,
+ numUnreadNotifications: ULong = 0uL,
+ numUnreadMentions: ULong = 0uL,
+) = RoomInfo(
+ id = id,
+ displayName = displayName,
+ rawName = rawName,
+ topic = topic,
+ avatarUrl = avatarUrl,
+ isDirect = isDirect,
+ isPublic = isPublic,
+ isSpace = isSpace,
+ isTombstoned = isTombstoned,
+ isFavourite = isFavourite,
+ canonicalAlias = canonicalAlias,
+ alternativeAliases = alternativeAliases,
+ membership = membership,
+ inviter = inviter,
+ heroes = heroes,
+ activeMembersCount = activeMembersCount,
+ invitedMembersCount = invitedMembersCount,
+ joinedMembersCount = joinedMembersCount,
+ userPowerLevels = userPowerLevels,
+ highlightCount = highlightCount,
+ notificationCount = notificationCount,
+ userDefinedNotificationMode = userDefinedNotificationMode,
+ hasRoomCall = hasRoomCall,
+ activeRoomCallParticipants = activeRoomCallParticipants,
+ isMarkedUnread = isMarkedUnread,
+ numUnreadMessages = numUnreadMessages,
+ numUnreadNotifications = numUnreadNotifications,
+ numUnreadMentions = numUnreadMentions
+)
+
+class FakeRoomListItem(
+ private val roomId: RoomId,
+ private val roomInfo: RoomInfo = aRustRoomInfo(id = roomId.value),
+ private val latestEvent: EventTimelineItem? = null,
+) : RoomListItem(NoPointer) {
+ override fun id(): String {
+ return roomId.value
+ }
+
+ override suspend fun roomInfo(): RoomInfo {
+ return roomInfo
+ }
+
+ override suspend fun latestEvent(): EventTimelineItem? {
+ return latestEvent
+ }
+
+ override fun subscribe(settings: RoomSubscription?) = Unit
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt
index 53ebf00f41..de03a43df5 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt
@@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
class FakeMatrixClientProvider(
- private val getClient: (SessionId) -> Result = { Result.success(FakeMatrixClient()) }
+ var getClient: (SessionId) -> Result = { Result.success(FakeMatrixClient()) }
) : MatrixClientProvider {
override suspend fun getOrRestore(sessionId: SessionId): Result = getClient(sessionId)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index d10d1ad0d2..a84fe1edf6 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -48,6 +48,7 @@ val A_SPACE_ID = SpaceId("!aSpaceId:domain")
val A_SPACE_ID_2 = SpaceId("!aSpaceId2:domain")
val A_ROOM_ID = RoomId("!aRoomId:domain")
val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
+val A_ROOM_ID_3 = RoomId("!aRoomId3:domain")
val A_THREAD_ID = ThreadId("\$aThreadId")
val A_THREAD_ID_2 = ThreadId("\$aThreadId2")
val AN_EVENT_ID = EventId("\$anEventId")
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
index 330db62274..b6827d13e5 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt
@@ -34,6 +34,7 @@ fun aNotificationData(
roomAvatarUrl = null,
roomDisplayName = null,
isDirect = false,
+ isDm = false,
isEncrypted = false,
isNoisy = false,
timestamp = 0L,
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
index f700c3b6af..3510a362ec 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
@@ -16,13 +16,19 @@
package io.element.android.libraries.matrix.test.permalink
+import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
class FakePermalinkBuilder(
- private val result: (UserId) -> Result = { Result.failure(Exception("Not implemented")) }
+ private val permalinkForUserLambda: (UserId) -> Result = { Result.failure(Exception("Not implemented")) },
+ private val permalinkForRoomAliasLambda: (RoomAlias) -> Result = { Result.failure(Exception("Not implemented")) },
) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result {
- return result(userId)
+ return permalinkForUserLambda(userId)
+ }
+
+ override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result {
+ return permalinkForRoomAliasLambda(roomAlias)
}
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 7b08b2ea63..cfd0267516 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -56,7 +56,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
-import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
@@ -80,131 +80,92 @@ class FakeMatrixRoom(
override val isPublic: Boolean = true,
override val isSpace: Boolean = false,
override val isDirect: Boolean = false,
- override val isOneToOne: Boolean = false,
override val joinedMemberCount: Long = 123L,
override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
override val liveTimeline: Timeline = FakeTimeline(),
- private var roomPermalinkResult: () -> Result = { Result.success("room link") },
- private var eventPermalinkResult: (EventId) -> Result = { Result.success("event link") },
- var sendCallNotificationIfNeededResult: () -> Result = { Result.success(Unit) },
- canRedactOwn: Boolean = false,
- canRedactOther: Boolean = false,
+ private var roomPermalinkResult: () -> Result = { lambdaError() },
+ private var eventPermalinkResult: (EventId) -> Result = { lambdaError() },
+ private val sendCallNotificationIfNeededResult: () -> Result = { lambdaError() },
+ private val userDisplayNameResult: () -> Result = { lambdaError() },
+ private val userAvatarUrlResult: () -> Result = { lambdaError() },
+ private val userRoleResult: () -> Result = { lambdaError() },
+ private val getUpdatedMemberResult: (UserId) -> Result