diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1ba71c1f61..4ff935fad1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -25,7 +25,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/develop' && format('integration-tests-develop-{0}-{1}', matrix.target, github.sha) || format('build-debug-{0}-{1}', matrix.target, github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: actions/cache@v2
with:
path: |
@@ -49,7 +49,7 @@ jobs:
if: github.ref == 'refs/heads/main'
# Only runs on main, no concurrency.
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: actions/cache@v2
with:
path: |
diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml
index ee4a87293f..4e701faa44 100644
--- a/.github/workflows/gradle-wrapper-validation.yml
+++ b/.github/workflows/gradle-wrapper-validation.yml
@@ -7,5 +7,5 @@ jobs:
runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index d087846f1e..455545aeef 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -14,50 +14,6 @@ env:
-Porg.gradle.jvmargs=-Xmx4g
-Porg.gradle.parallel=false
jobs:
- # Build Android Tests [Matrix SDK]
- build-android-test-matrix-sdk:
- name: Matrix SDK - Build Android Tests
- runs-on: macos-latest
- # No concurrency required, runs every time on a schedule.
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-java@v2
- with:
- distribution: 'adopt'
- java-version: 11
- - uses: actions/cache@v2
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
- - name: Build Android Tests for matrix-sdk-android
- run: ./gradlew clean matrix-sdk-android:assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES --stacktrace
-
- # Build Android Tests [Matrix APP]
- build-android-test-app:
- name: App - Build Android Tests
- runs-on: macos-latest
- # No concurrency required, runs every time on a schedule.
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-java@v2
- with:
- distribution: 'adopt'
- java-version: 11
- - uses: actions/cache@v2
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
- - name: Build Android Tests for vector
- run: ./gradlew clean vector:assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES --stacktrace
-
# Run Android Tests
integration-tests:
name: Matrix SDK - Running Integration Tests
@@ -68,7 +24,7 @@ jobs:
api-level: [ 28 ]
# No concurrency required, runs every time on a schedule.
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1
- uses: actions/setup-java@v2
with:
@@ -87,11 +43,11 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- name: Start synapse server
- run: |
- pip install matrix-synapse
- curl https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh -o start.sh
- chmod 777 start.sh
- ./start.sh --no-rate-limit
+ uses: michaelkaye/setup-matrix-synapse@v0.3.0
+ with:
+ uploadLogs: true
+ httpPort: 8080
+ disableRateLimiting: true
# package: org.matrix.android.sdk.session
- name: Run integration tests for Matrix SDK [org.matrix.android.sdk.session] API[${{ matrix.api-level }}]
uses: reactivecircus/android-emulator-runner@v2
@@ -260,7 +216,7 @@ jobs:
api-level: [ 28 ]
# No concurrency required, runs every time on a schedule.
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Set up Python 3.8
uses: actions/setup-python@v3
with:
@@ -274,10 +230,11 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- name: Start synapse server
- run: |
- pip install matrix-synapse
- curl -sL https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh \
- | sed s/127.0.0.1/0.0.0.0/g | sed 's/http:\/\/localhost/http:\/\/10.0.2.2/g' | bash -s -- --no-rate-limit
+ uses: michaelkaye/setup-matrix-synapse@v0.3.0
+ with:
+ uploadLogs: true
+ httpPort: 8080
+ disableRateLimiting: true
- uses: actions/setup-java@v2
with:
distribution: 'adopt'
@@ -308,9 +265,10 @@ jobs:
failure_screenshots/
codecov-units:
+ name: Unit tests with code coverage
runs-on: macos-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: actions/setup-java@v2
with:
distribution: 'adopt'
@@ -333,12 +291,13 @@ jobs:
build/reports/jacoco/allCodeCoverageReport/allCodeCoverageReport.xml
sonarqube:
+ name: Sonarqube upload
runs-on: macos-latest
if: always()
needs:
- codecov-units
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: actions/setup-java@v2
with:
distribution: 'adopt'
@@ -362,13 +321,11 @@ jobs:
# Notify the channel about scheduled runs, do not notify for manually triggered runs
notify:
+ name: Notify matrix
runs-on: ubuntu-latest
needs:
- integration-tests
- ui-tests
-# - unit-tests
- - build-android-test-matrix-sdk
- - build-android-test-app
- sonarqube
if: always() && github.event_name != 'workflow_dispatch'
# No concurrency required, runs every time on a schedule.
@@ -379,4 +336,4 @@ jobs:
matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }}
matrix_room_id: ${{ secrets.ELEMENT_ANDROID_INTERNAL_ROOM_ID }}
text_template: "Nightly test run: {{#each job_statuses }}{{#with this }}{{#if completed }} {{name}} {{conclusion}} at {{completed_at}}, {{/if}}{{/with}}{{/each}}"
- html_template: "Nightly test run results: {{#each job_statuses }}{{#with this }}{{#if completed }}
{{name}} {{conclusion}} at {{completed_at}} [details]{{/if}}{{/with}}{{/each}}"
+ html_template: "Nightly test run results: {{#each job_statuses }}{{#with this }}{{#if completed }}
{{icon conclusion}} {{name}} {{conclusion}} at {{completed_at}} [details]{{/if}}{{/with}}{{/each}}"
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 02827e7f17..a588b91449 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -10,7 +10,7 @@ jobs:
name: Project Check Suite
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Run code quality check suite
run: ./tools/check/check_code_quality.sh
@@ -23,7 +23,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('ktlint-develop-{0}', github.sha) || format('ktlint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Run ktlint
run: |
./gradlew ktlintCheck --continue
@@ -96,7 +96,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('android-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('android-lint-develop-{0}', github.sha) || format('android-lint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: actions/cache@v2
with:
path: |
@@ -129,7 +129,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/develop' && format('apk-lint-develop-{0}-{1}', matrix.target, github.sha) || format('apk-lint-{0}-{1}', matrix.target, github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: actions/cache@v2
with:
path: |
diff --git a/.github/workflows/sync-from-external-sources.yml b/.github/workflows/sync-from-external-sources.yml
index 55873c9112..d390c47696 100644
--- a/.github/workflows/sync-from-external-sources.yml
+++ b/.github/workflows/sync-from-external-sources.yml
@@ -11,7 +11,7 @@ jobs:
if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule.
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Set up Python 3.8
uses: actions/setup-python@v3
with:
@@ -38,7 +38,7 @@ jobs:
if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule.
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Set up Python 3.8
uses: actions/setup-python@v3
with:
@@ -64,7 +64,7 @@ jobs:
if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule.
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Run analytics import script
run: ./tools/import_analytic_plan.sh
- name: Create Pull Request for analytics plan
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a579e0f786..587bf14488 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -12,6 +12,30 @@ env:
-Porg.gradle.parallel=false
jobs:
+ # Build Android Tests
+ build-android-tests:
+ name: Build Android Tests
+ runs-on: ubuntu-latest
+ concurrency:
+ group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('build-android-tests-{0}', github.ref) }}
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-java@v2
+ with:
+ distribution: 'adopt'
+ java-version: 11
+ - uses: actions/cache@v2
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+ - name: Build Android Tests
+ run: ./gradlew clean assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES --stacktrace
+
unit-tests:
name: Run Unit Tests
runs-on: ubuntu-latest
@@ -20,7 +44,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: actions/cache@v2
with:
path: |
@@ -41,3 +65,20 @@ jobs:
( github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository )
with:
files: ./**/build/test-results/**/*.xml
+
+# Notify the channel about runs against develop or main that have failures, as PRs should have caught these first.
+ notify:
+ runs-on: ubuntu-latest
+ needs:
+ - unit-tests
+ - build-android-tests
+ if: ${{ (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' ) && failure() }}
+ steps:
+ - uses: michaelkaye/matrix-hookshot-action@v0.3.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }}
+ matrix_room_id: ${{ secrets.ELEMENT_ANDROID_INTERNAL_ROOM_ID }}
+ text_template: "Build is broken for ${{ github.ref }}: {{#each job_statuses }}{{#with this }}{{#if completed }}{{name}} {{conclusion}} at {{completed_at}}, {{/if}}{{/with}}{{/each}}"
+ html_template: "Build is broken for ${{ github.ref }}: {{#each job_statuses }}{{#with this }}{{#if completed }}
{{icon conclusion }} {{name}} {{conclusion}} at {{completed_at}} [details]{{/if}}{{/with}}{{/each}}"
+
diff --git a/.github/workflows/update-gradle-wrapper.yml b/.github/workflows/update-gradle-wrapper.yml
index 4a786a9339..1cbf29cc8d 100644
--- a/.github/workflows/update-gradle-wrapper.yml
+++ b/.github/workflows/update-gradle-wrapper.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1
diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml
index ed572b573f..85290e72df 100644
--- a/.idea/dictionaries/bmarty.xml
+++ b/.idea/dictionaries/bmarty.xml
@@ -11,6 +11,7 @@
emoji
emojis
fdroid
+ ganfra
gplay
hmac
homeserver
@@ -18,6 +19,7 @@
ktlint
linkified
linkify
+ manu
megolm
msisdn
msisdns
diff --git a/CHANGES.md b/CHANGES.md
index c411593627..2a97120452 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,74 @@
+Changes in Element v1.4.8 (2022-03-28)
+======================================
+
+Other changes
+-------------
+ - Moving live location sharing permission to debug only builds whilst it is WIP ([#5636](https://github.com/vector-im/element-android/issues/5636))
+
+
+Changes in Element v1.4.7 (2022-03-24)
+======================================
+
+Bugfixes 🐛
+----------
+ - Fix inconsistencies between the arrow visibility and the collapse action on the room sections ([#5616](https://github.com/vector-im/element-android/issues/5616))
+ - Fix room list header count flickering
+
+Changes in Element v1.4.6 (2022-03-23)
+======================================
+
+Features ✨
+----------
+ - Thread timeline is now live and much faster especially for large or old threads ([#5230](https://github.com/vector-im/element-android/issues/5230))
+ - View all threads per room screen is now live when the home server supports threads ([#5232](https://github.com/vector-im/element-android/issues/5232))
+ - Add a custom view to display a picker for share location options ([#5395](https://github.com/vector-im/element-android/issues/5395))
+ - Add ability to pin a location on map for sharing ([#5417](https://github.com/vector-im/element-android/issues/5417))
+ - Poll Integration Tests ([#5522](https://github.com/vector-im/element-android/issues/5522))
+ - Live location sharing: adding build config field and show permission dialog ([#5536](https://github.com/vector-im/element-android/issues/5536))
+ - Live location sharing: Adding indicator view when enabled ([#5571](https://github.com/vector-im/element-android/issues/5571))
+
+Bugfixes 🐛
+----------
+ - Poll system notifications on Android are not user friendly ([#4780](https://github.com/vector-im/element-android/issues/4780))
+ - Add colors for shield vector drawable ([#4860](https://github.com/vector-im/element-android/issues/4860))
+ - Support both stable and unstable prefixes for Events about Polls and Location ([#5340](https://github.com/vector-im/element-android/issues/5340))
+ - Fix missing messages when loading messages forwards ([#5448](https://github.com/vector-im/element-android/issues/5448))
+ - Fix presence indicator being aligned to the center of the room image ([#5489](https://github.com/vector-im/element-android/issues/5489))
+ - Read receipt in wrong order ([#5514](https://github.com/vector-im/element-android/issues/5514))
+ - Fix mentions using matrix.to rather than client defined permalink base url ([#5521](https://github.com/vector-im/element-android/issues/5521))
+ - Fixes crash when tapping the timeline verification surround box instead of the buttons ([#5540](https://github.com/vector-im/element-android/issues/5540))
+ - [Notification mode] Wrong mode is displayed when the mention only is selected on the web client ([#5547](https://github.com/vector-im/element-android/issues/5547))
+ - Fix local echos not being shown when re-opening rooms ([#5551](https://github.com/vector-im/element-android/issues/5551))
+ - Fix crash when closing a room while decrypting timeline events ([#5552](https://github.com/vector-im/element-android/issues/5552))
+ - Fix sometimes read marker not properly updating ([#5564](https://github.com/vector-im/element-android/issues/5564))
+
+In development 🚧
+----------------
+ - Dynamically showing/hiding onboarding personalisation screens based on the users homeserver capabilities ([#5375](https://github.com/vector-im/element-android/issues/5375))
+ - Introduces FTUE personalisation complete screen along with confetti celebration ([#5389](https://github.com/vector-im/element-android/issues/5389))
+
+SDK API changes ⚠️
+------------------
+ - Adds support for MSC3440, additional threads homeserver capabilities ([#5271](https://github.com/vector-im/element-android/issues/5271))
+
+Other changes
+-------------
+ - Refactoring for safer olm and megolm session usage ([#5380](https://github.com/vector-im/element-android/issues/5380))
+ - Improve headers UI in Rooms/Messages lists ([#4533](https://github.com/vector-im/element-android/issues/4533))
+ - Number of unread messages on space badge now include number of unread DMs ([#5260](https://github.com/vector-im/element-android/issues/5260))
+ - Amend spaces menu to be consistent with iOS version ([#5270](https://github.com/vector-im/element-android/issues/5270))
+ - Selected space highlight changed in left panel ([#5346](https://github.com/vector-im/element-android/issues/5346))
+ - [Rooms list] Do not suggest collapse the unique section ([#5347](https://github.com/vector-im/element-android/issues/5347))
+ - Add analytics support for threads ([#5378](https://github.com/vector-im/element-android/issues/5378))
+ - Add top margin before our first message ([#5384](https://github.com/vector-im/element-android/issues/5384))
+ - Improved onboarding registration unit test coverage ([#5408](https://github.com/vector-im/element-android/issues/5408))
+ - Adds stable room hierarchy endpoint with a fallback to the unstable one ([#5443](https://github.com/vector-im/element-android/issues/5443))
+ - Use ColorPrimary for attachmentGalleryButton tint ([#5501](https://github.com/vector-im/element-android/issues/5501))
+ - Added online presence indicator attribute online to match offline styling ([#5513](https://github.com/vector-im/element-android/issues/5513))
+ - Add a presence sync enabling build config ([#5563](https://github.com/vector-im/element-android/issues/5563))
+ - Show stickers on click ([#5572](https://github.com/vector-im/element-android/issues/5572))
+
+
Changes in Element v1.4.4 (2022-03-09)
======================================
diff --git a/dependencies.gradle b/dependencies.gradle
index 89b63993e6..3440ee62b6 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -59,6 +59,7 @@ ext.libs = [
'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle",
'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle",
'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle",
+ 'lifecycleRuntimeKtx' : "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle",
'datastore' : "androidx.datastore:datastore:1.0.0",
'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0",
'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2",
diff --git a/fastlane/metadata/android/en-US/changelogs/40104060.txt b/fastlane/metadata/android/en-US/changelogs/40104060.txt
new file mode 100644
index 0000000000..1863bef5fb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40104060.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Thread timeline are now live and faster. Various bug fixes and stability improvements.
+Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.4.6
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/40104070.txt b/fastlane/metadata/android/en-US/changelogs/40104070.txt
new file mode 100644
index 0000000000..99a3ecfe7b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40104070.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Various bug fixes and stability improvements.
+Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.4.7
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/40104080.txt b/fastlane/metadata/android/en-US/changelogs/40104080.txt
new file mode 100644
index 0000000000..66ed1664bd
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40104080.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Thread timeline are now live and faster. Various bug fixes and stability improvements.
+Full changelog: https://github.com/vector-im/element-android/releases
\ No newline at end of file
diff --git a/fastlane/metadata/android/es-ES/changelogs/40104000.txt b/fastlane/metadata/android/es-ES/changelogs/40104000.txt
new file mode 100644
index 0000000000..ea607fe19a
--- /dev/null
+++ b/fastlane/metadata/android/es-ES/changelogs/40104000.txt
@@ -0,0 +1,2 @@
+Principales cambios de esta versión: primera implementación de los hilos de mensajes. Burbujas de mensajes.
+Todos los cambios en: https://github.com/vector-im/element-android/releases/tag/v1.4.0
diff --git a/fastlane/metadata/android/es-ES/changelogs/40104020.txt b/fastlane/metadata/android/es-ES/changelogs/40104020.txt
new file mode 100644
index 0000000000..8c2c78cb62
--- /dev/null
+++ b/fastlane/metadata/android/es-ES/changelogs/40104020.txt
@@ -0,0 +1,2 @@
+Principales cambios de esta versión: añadir @room, encuestas cerradas y muchos cambios menores más.
+Todos los cambios en: https://github.com/vector-im/element-android/releases/tag/v1.4.2
diff --git a/fastlane/metadata/android/fa/changelogs/40104000.txt b/fastlane/metadata/android/fa/changelogs/40104000.txt
new file mode 100644
index 0000000000..7beb79981f
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/40104000.txt
@@ -0,0 +1,2 @@
+تغییرات اصلی در این نگارش: پیاده سازی نخستین پیامهای رشتهای. حبابهای پیام.
+گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.4.0
diff --git a/fastlane/metadata/android/fa/changelogs/40104020.txt b/fastlane/metadata/android/fa/changelogs/40104020.txt
new file mode 100644
index 0000000000..6d5148220d
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/40104020.txt
@@ -0,0 +1,2 @@
+تغییرات اصلی در این نگارش: افزودن پشتیبانی به @room و نظرسنجیهای فاش نشده در کنار تغییرات کوچک دیگر.
+گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.4.2
diff --git a/fastlane/metadata/android/hu-HU/full_description.txt b/fastlane/metadata/android/hu-HU/full_description.txt
index 0791eed7ba..b43613eb20 100644
--- a/fastlane/metadata/android/hu-HU/full_description.txt
+++ b/fastlane/metadata/android/hu-HU/full_description.txt
@@ -8,12 +8,13 @@ Az Element egy biztonságos üzenetküldő, és egy csapatmunka app, amely távo
- Videochat, VoIP, és képernyőmegosztási lehetőséggel
- Egyszerű integráció a kedvenc online kollaborációs eszközeiddel, projektkezelési eszközökkel, VoIP szolgáltatásokkal, és más csoportos üzenetküldő alkalmazásokkal
-Element is completely different from other messaging and collaboration apps. It operates on Matrix, an open network for secure messaging and decentralized communication. It allows self-hosting to give users maximum ownership and control of their data and messages.
+Az Element teljesen más, mint az összes többi üzenetküldő és kollaborációs alkalmazás. A biztonságos üzenetküldést és decentralizált kommunikációt biztosító Matrix platformot használja. Akár egyénileg üzemeltetett szervereket is lehet használni az adatok teljes kontrollálása érdekében.
-Privacy and encrypted messaging
-Element protects you from unwanted ads, data mining and walled gardens. It also secures all your data, one-to-one video and voice communication through end-to-end encryption and cross-signed device verification.
+Magánszféra és titkosított csevegés
+Az Element megvéd a nemkívánatos hirdetésektől, adatbányászattól, és a zárt platformoktól. Ezeken felül biztonságban tartja az összes adatod és 1:1 hívásod a végponti titkosításnak és az eszközök-közti hitelesítésnek köszönhetően.
+
+Az Element átadja neked az irányítást a magánszférád felett, miközben lehetővé teszi, hogy biztonságosan kommunikálj bárkivel a Matrix hálózatban, vagy a többi üzleti kommunikációs eszközt használókkal, az olyan appok integrálásának köszönhetően, mint például a Slack.
-Element gives you control over your privacy while allowing you to communicate securely with anyone on the Matrix network, or other business collaboration tools by integrating with apps such as Slack.
Element can be self-hosted
To allow more control of your sensitive data and conversations, Element can be self-hosted or you can choose any Matrix-based host - the standard for open source, decentralized communication. Element gives you privacy, security compliance and integration flexibility.
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104000.txt b/fastlane/metadata/android/ru-RU/changelogs/40104000.txt
new file mode 100644
index 0000000000..f6bf34b3cc
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104000.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: Начальная реализация веток сообщений. Сообщения пузыри.
+Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.4.0
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104020.txt b/fastlane/metadata/android/ru-RU/changelogs/40104020.txt
new file mode 100644
index 0000000000..864bd03d5e
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104020.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: добавлена поддержка @room и нераскрытых опросов, а также множество других мелких изменений.
+Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.4.2
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index dcf5e2cb7b..db3bccc1f9 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
+distributionSha256Sum=a9a7b7baba105f6557c9dcf9c3c6e8f7e57e6b49889c5f1d133f015d0727e4be
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/library/jsonviewer/build.gradle b/library/jsonviewer/build.gradle
index 15f46754b3..0cad8ac171 100644
--- a/library/jsonviewer/build.gradle
+++ b/library/jsonviewer/build.gradle
@@ -59,7 +59,7 @@ dependencies {
implementation libs.jetbrains.coroutinesCore
implementation libs.jetbrains.coroutinesAndroid
- testImplementation 'org.json:json:20211205'
+ testImplementation 'org.json:json:20220320'
testImplementation libs.tests.junit
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espressoCore
diff --git a/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt b/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt
index 227ac2a71d..00d66645e6 100644
--- a/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt
+++ b/library/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/ValueItem.kt
@@ -20,7 +20,6 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.ContextMenu
-import android.view.Menu
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
@@ -77,10 +76,7 @@ internal abstract class ValueItem : EpoxyModelWithHolder() {
menuInfo: ContextMenu.ContextMenuInfo?
) {
if (copyValue != null) {
- val menuItem = menu?.add(
- Menu.NONE, R.id.copy_value,
- Menu.NONE, R.string.copy_value
- )
+ val menuItem = menu?.add(R.string.copy_value)
val clipService =
v?.context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
menuItem?.setOnMenuItemClickListener {
diff --git a/library/jsonviewer/src/main/res/menu/jv_menu_item.xml b/library/jsonviewer/src/main/res/menu/jv_menu_item.xml
deleted file mode 100644
index 4da69b5117..0000000000
--- a/library/jsonviewer/src/main/res/menu/jv_menu_item.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
diff --git a/library/jsonviewer/src/main/res/values/strings.xml b/library/jsonviewer/src/main/res/values/strings.xml
index cc4b8726b4..fbd67256f5 100644
--- a/library/jsonviewer/src/main/res/values/strings.xml
+++ b/library/jsonviewer/src/main/res/values/strings.xml
@@ -1,3 +1,4 @@
+
Copy Value
diff --git a/library/ui-styles/src/debug/res/layout/activity_debug_button_styles.xml b/library/ui-styles/src/debug/res/layout/activity_debug_button_styles.xml
index 0f129fb406..cc15bb1b3b 100644
--- a/library/ui-styles/src/debug/res/layout/activity_debug_button_styles.xml
+++ b/library/ui-styles/src/debug/res/layout/activity_debug_button_styles.xml
@@ -71,19 +71,6 @@
android:enabled="false"
android:text="Destructive disabled" />
-
-
-
-
-
-
-
-
diff --git a/library/ui-styles/src/main/res/color/button_social_google_background_selector_dark.xml b/library/ui-styles/src/main/res/color/button_social_google_background_selector_dark.xml
deleted file mode 100644
index 3893ce3e34..0000000000
--- a/library/ui-styles/src/main/res/color/button_social_google_background_selector_dark.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/library/ui-styles/src/main/res/values-land/styles_dial_pad.xml b/library/ui-styles/src/main/res/values-land/styles_dial_pad.xml
deleted file mode 100644
index 39c5bf9aa6..0000000000
--- a/library/ui-styles/src/main/res/values-land/styles_dial_pad.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/library/ui-styles/src/main/res/values/attrs_sc.xml b/library/ui-styles/src/main/res/values/attrs_sc.xml
index b6f45e4c17..1cfc108b10 100644
--- a/library/ui-styles/src/main/res/values/attrs_sc.xml
+++ b/library/ui-styles/src/main/res/values/attrs_sc.xml
@@ -46,8 +46,6 @@
-
-
diff --git a/library/ui-styles/src/main/res/values/colors.xml b/library/ui-styles/src/main/res/values/colors.xml
index ce6900e4b6..fdf53be5bd 100644
--- a/library/ui-styles/src/main/res/values/colors.xml
+++ b/library/ui-styles/src/main/res/values/colors.xml
@@ -9,10 +9,6 @@
#2f9edb
?colorError
-
- #14368BD6
- @color/palette_azure
-
@color/palette_azure
@color/palette_melon
@@ -22,7 +18,6 @@
#99000000
#27303A
- #FF61708B
#1E61708B
@@ -86,16 +81,6 @@
#BF000000
#BF000000
-
- #FFFFFFFF
- #FF22262E
- #FF090A0C
-
-
- #FFE9EDF1
- #FF22262E
- #FF090A0C
-
#EBEFF5
#27303A
@@ -110,9 +95,7 @@
#AAAAAAAA
#55555555
-
#EEEEEE
- #61708B
#FFF3F8FD
@@ -142,4 +125,18 @@
@color/palette_gray_100
@color/palette_gray_450
+
+ @color/palette_element_green
+ @color/palette_element_green
+
+
+
+ @color/palette_prune
+ @color/palette_prune
+
+
+ #8BC34A
+ #212121
+ #FF4B55
+
diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml
index c8dc2e5fc4..d2c97404f1 100644
--- a/library/ui-styles/src/main/res/values/dimens.xml
+++ b/library/ui-styles/src/main/res/values/dimens.xml
@@ -9,20 +9,11 @@
32dp
50dp
- 16dp
- 196dp
- 44dp
- 72dp
16dp
32dp
- 40dp
- 60dp
-
- 4dp
8dp
- 8dp
- 0.75
@@ -74,4 +65,10 @@
- 0.15
- 0.05
+
+
+ 10dp
+ 16dp
+ 12dp
+ 8dp
diff --git a/library/ui-styles/src/main/res/values/palette.xml b/library/ui-styles/src/main/res/values/palette.xml
index e37fd8a7c6..e6cee80b59 100644
--- a/library/ui-styles/src/main/res/values/palette.xml
+++ b/library/ui-styles/src/main/res/values/palette.xml
@@ -1,8 +1,12 @@
-
+
-
+
#368BD6
@@ -15,6 +19,7 @@
#0DBD8B
#FFFFFF
#FF5B55
+
#7E69FF
#2DC2C5
#5C56F5
@@ -27,6 +32,7 @@
#8D97A5
#737D8C
#17191C
+
#F4F9FD
diff --git a/library/ui-styles/src/main/res/values/palette_mobile.xml b/library/ui-styles/src/main/res/values/palette_mobile.xml
index c22b9705c7..5610771f8a 100644
--- a/library/ui-styles/src/main/res/values/palette_mobile.xml
+++ b/library/ui-styles/src/main/res/values/palette_mobile.xml
@@ -35,7 +35,6 @@
@color/palette_gray_25
@color/palette_black_950
- @color/palette_black_950
@color/palette_white
@color/palette_black_800
@@ -54,5 +53,4 @@
@color/palette_verde
@color/palette_azure
@color/palette_grape
-
\ No newline at end of file
diff --git a/library/ui-styles/src/main/res/values/stylable_location_sharing_option_picker_view.xml b/library/ui-styles/src/main/res/values/stylable_location_sharing_option_picker_view.xml
new file mode 100644
index 0000000000..25b2687fed
--- /dev/null
+++ b/library/ui-styles/src/main/res/values/stylable_location_sharing_option_picker_view.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/library/ui-styles/src/main/res/values/stylable_map_tiler_map_view.xml b/library/ui-styles/src/main/res/values/stylable_map_tiler_map_view.xml
new file mode 100644
index 0000000000..a7c45918af
--- /dev/null
+++ b/library/ui-styles/src/main/res/values/stylable_map_tiler_map_view.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/library/ui-styles/src/main/res/values/styles_attachments.xml b/library/ui-styles/src/main/res/values/styles_attachments.xml
deleted file mode 100644
index 18c2e3f95f..0000000000
--- a/library/ui-styles/src/main/res/values/styles_attachments.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/library/ui-styles/src/main/res/values/styles_buttons.xml b/library/ui-styles/src/main/res/values/styles_buttons.xml
index e445dc5a1d..21d51c4901 100644
--- a/library/ui-styles/src/main/res/values/styles_buttons.xml
+++ b/library/ui-styles/src/main/res/values/styles_buttons.xml
@@ -32,23 +32,6 @@
-
-
-
-
-
-
-
-
diff --git a/library/ui-styles/src/main/res/values/styles_dial_pad.xml b/library/ui-styles/src/main/res/values/styles_dial_pad.xml
index 34e128c56d..77dc0b3081 100644
--- a/library/ui-styles/src/main/res/values/styles_dial_pad.xml
+++ b/library/ui-styles/src/main/res/values/styles_dial_pad.xml
@@ -1,5 +1,8 @@
-
+
+
+
+
+
diff --git a/library/ui-styles/src/main/res/values/styles_social_login.xml b/library/ui-styles/src/main/res/values/styles_social_login.xml
index 5a76f70f2e..810ce14c6b 100644
--- a/library/ui-styles/src/main/res/values/styles_social_login.xml
+++ b/library/ui-styles/src/main/res/values/styles_social_login.xml
@@ -28,11 +28,6 @@
- @color/black_54
-
-
diff --git a/library/ui-styles/src/main/res/values/theme_black.xml b/library/ui-styles/src/main/res/values/theme_black.xml
index 44d4206d43..6e5ce80c19 100644
--- a/library/ui-styles/src/main/res/values/theme_black.xml
+++ b/library/ui-styles/src/main/res/values/theme_black.xml
@@ -11,8 +11,6 @@
- @color/vctr_fab_label_stroke_black
- @color/vctr_fab_label_color_black
- @color/vctr_touch_guard_bg_black
- - @color/vctr_attachment_selector_background_black
- - @color/vctr_attachment_selector_border_black
- @color/vctr_room_active_widgets_banner_bg_black
- @color/vctr_room_active_widgets_banner_text_black
- @color/vctr_reaction_background_off_black
diff --git a/library/ui-styles/src/main/res/values/theme_dark.xml b/library/ui-styles/src/main/res/values/theme_dark.xml
index 12487c65c6..6b8f3e69c1 100644
--- a/library/ui-styles/src/main/res/values/theme_dark.xml
+++ b/library/ui-styles/src/main/res/values/theme_dark.xml
@@ -21,8 +21,6 @@
- @color/vctr_fab_label_color_dark
- @color/vctr_touch_guard_bg_dark
- @color/vctr_keys_backup_banner_accent_color_dark
- - @color/vctr_attachment_selector_background_dark
- - @color/vctr_attachment_selector_border_dark
- @color/vctr_room_active_widgets_banner_bg_dark
- @color/vctr_room_active_widgets_banner_text_dark
- @color/vctr_reaction_background_off_dark
@@ -45,6 +43,7 @@
- @color/vctr_presence_indicator_offline_dark
+ - @color/vctr_presence_indicator_online_dark
- ?vctr_system
@@ -183,6 +182,8 @@
- @style/Widget.Vector.ActionButton
+
+ - @color/vctr_live_location_dark
diff --git a/library/ui-styles/src/main/res/values/theme_light.xml b/library/ui-styles/src/main/res/values/theme_light.xml
index 9127c4af79..75f6a78703 100644
--- a/library/ui-styles/src/main/res/values/theme_light.xml
+++ b/library/ui-styles/src/main/res/values/theme_light.xml
@@ -21,8 +21,6 @@
- @color/vctr_fab_label_color_light
- @color/vctr_touch_guard_bg_light
- @color/vctr_keys_backup_banner_accent_color_light
- - @color/vctr_attachment_selector_background_light
- - @color/vctr_attachment_selector_border_light
- @color/vctr_room_active_widgets_banner_bg_light
- @color/vctr_room_active_widgets_banner_text_light
- @color/vctr_reaction_background_off_light
@@ -45,6 +43,7 @@
- @color/vctr_presence_indicator_offline_light
+ - @color/vctr_presence_indicator_online_light
- ?vctr_system
@@ -184,6 +183,8 @@
- @style/Widget.Vector.ActionButton
+
+ - @color/vctr_live_location_light
diff --git a/library/ui-styles/src/main/res/values/theme_sc.xml b/library/ui-styles/src/main/res/values/theme_sc.xml
index 92b92f910b..7374ced81e 100644
--- a/library/ui-styles/src/main/res/values/theme_sc.xml
+++ b/library/ui-styles/src/main/res/values/theme_sc.xml
@@ -20,8 +20,6 @@
- @android:color/white
- @color/background_touch_guard_sc
- @color/background_floating_sc
- - ?colorBackgroundFloating
- - @color/list_divider_color_sc
- @color/background_floating_sc
- ?vctr_content_secondary
- @color/background_sc
diff --git a/library/ui-styles/src/main/res/values/theme_sc_light.xml b/library/ui-styles/src/main/res/values/theme_sc_light.xml
index 7ab07b301d..74da50a37e 100644
--- a/library/ui-styles/src/main/res/values/theme_sc_light.xml
+++ b/library/ui-styles/src/main/res/values/theme_sc_light.xml
@@ -20,8 +20,6 @@
- ?vctr_content_primary
- @color/background_touch_guard_sc_light
- @color/background_floating_sc_light
- - ?colorBackgroundFloating
- - @color/list_divider_color_sc_light
- @color/background_floating_sc_light
- ?vctr_content_secondary
- @color/background_sc_light_secondary
diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
index c5d1d19fec..d737715306 100644
--- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
+++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
@@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState
import org.matrix.android.sdk.api.session.room.send.UserDraft
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
@@ -101,13 +102,18 @@ class FlowRoom(private val room: Room) {
return room.getLiveRoomNotificationState().asFlow()
}
+ fun liveThreadSummaries(): Flow> {
+ return room.getAllThreadSummariesLive().asFlow()
+ .startWith(room.coroutineDispatchers.io) {
+ room.getAllThreadSummaries()
+ }
+ }
fun liveThreadList(): Flow> {
return room.getAllThreadsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreads()
}
}
-
fun liveLocalUnreadThreadList(): Flow> {
return room.getMarkedThreadNotificationsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle
index 3e301eebb9..1e2eda166f 100644
--- a/matrix-sdk-android/build.gradle
+++ b/matrix-sdk-android/build.gradle
@@ -31,12 +31,11 @@ android {
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
- buildConfigField "String", "SDK_VERSION", "\"1.4.4\""
+ buildConfigField "String", "SDK_VERSION", "\"1.4.8\""
buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
- resValue "string", "git_sdk_revision", "\"${gitRevision()}\""
- resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\""
- resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\""
+ buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\""
+ buildConfigField "String", "GIT_SDK_REVISION_DATE", "\"${gitRevisionDate()}\""
defaultConfig {
consumerProguardFiles 'proguard-rules.pro'
@@ -167,7 +166,7 @@ dependencies {
implementation libs.apache.commonsImaging
// Phone number https://github.com/google/libphonenumber
- implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.44'
+ implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.45'
testImplementation libs.tests.junit
testImplementation 'org.robolectric:robolectric:4.7.3'
@@ -175,7 +174,7 @@ dependencies {
// Note: version sticks to 1.9.2 due to https://github.com/mockk/mockk/issues/281
testImplementation libs.mockk.mockk
testImplementation libs.tests.kluent
- implementation libs.jetbrains.coroutinesAndroid
+ testImplementation libs.jetbrains.coroutinesTest
// Plant Timber tree for test
testImplementation 'net.lachlanmckee:timber-junit-rule:1.0.1'
// Transitively required for mocking realm as monarchy doesn't expose Rx
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt
index 5c9b79361e..0f79896b2c 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestConstants.kt
@@ -23,7 +23,7 @@ object TestConstants {
const val TESTS_HOME_SERVER_URL = "http://10.0.2.2:8080"
// Time out to use when waiting for server response.
- private const val AWAIT_TIME_OUT_MILLIS = 30_000
+ private const val AWAIT_TIME_OUT_MILLIS = 60_000
// Time out to use when waiting for server response, when the debugger is connected. 10 minutes
private const val AWAIT_TIME_OUT_WITH_DEBUGGER_MILLIS = 10 * 60_000
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt
new file mode 100644
index 0000000000..41ec69cdc5
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt
@@ -0,0 +1,649 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.crypto
+
+import android.util.Log
+import androidx.test.filters.LargeTest
+import kotlinx.coroutines.delay
+import org.amshove.kluent.fail
+import org.amshove.kluent.internal.assertEquals
+import org.junit.Assert
+import org.junit.FixMethodOrder
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.junit.runners.MethodSorters
+import org.matrix.android.sdk.InstrumentedTest
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.session.crypto.MXCryptoError
+import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.events.model.toModel
+import org.matrix.android.sdk.api.session.room.Room
+import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure
+import org.matrix.android.sdk.api.session.room.model.Membership
+import org.matrix.android.sdk.api.session.room.model.message.MessageContent
+import org.matrix.android.sdk.api.session.room.send.SendState
+import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
+import org.matrix.android.sdk.common.CommonTestHelper
+import org.matrix.android.sdk.common.CryptoTestHelper
+import org.matrix.android.sdk.common.SessionTestParams
+import org.matrix.android.sdk.common.TestConstants
+import org.matrix.android.sdk.common.TestMatrixCallback
+import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
+import org.matrix.android.sdk.internal.crypto.keysbackup.model.MegolmBackupCreationInfo
+import org.matrix.android.sdk.internal.crypto.keysbackup.model.rest.KeysVersion
+import org.matrix.android.sdk.internal.crypto.keysbackup.model.rest.KeysVersionResult
+import org.matrix.android.sdk.internal.crypto.model.ImportRoomKeysResult
+import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
+
+@RunWith(JUnit4::class)
+@FixMethodOrder(MethodSorters.JVM)
+@LargeTest
+class E2eeSanityTests : InstrumentedTest {
+
+ private val testHelper = CommonTestHelper(context())
+ private val cryptoTestHelper = CryptoTestHelper(testHelper)
+
+ /**
+ * Simple test that create an e2ee room.
+ * Some new members are added, and a message is sent.
+ * We check that the message is e2e and can be decrypted.
+ *
+ * Additional users join, we check that they can't decrypt history
+ *
+ * Alice sends a new message, then check that the new one can be decrypted
+ */
+ @Test
+ fun testSendingE2EEMessages() {
+ val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
+ val aliceSession = cryptoTestData.firstSession
+ val e2eRoomID = cryptoTestData.roomId
+
+ val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!!
+
+ // add some more users and invite them
+ val otherAccounts = listOf("benoit", "valere", "ganfra") // , "adam", "manu")
+ .map {
+ testHelper.createAccount(it, SessionTestParams(true))
+ }
+
+ Log.v("#E2E TEST", "All accounts created")
+ // we want to invite them in the room
+ otherAccounts.forEach {
+ testHelper.runBlockingTest {
+ Log.v("#E2E TEST", "Alice invites ${it.myUserId}")
+ aliceRoomPOV.invite(it.myUserId)
+ }
+ }
+
+ // All user should accept invite
+ otherAccounts.forEach { otherSession ->
+ waitForAndAcceptInviteInRoom(otherSession, e2eRoomID)
+ Log.v("#E2E TEST", "${otherSession.myUserId} joined room $e2eRoomID")
+ }
+
+ // check that alice see them as joined (not really necessary?)
+ ensureMembersHaveJoined(aliceSession, otherAccounts, e2eRoomID)
+
+ Log.v("#E2E TEST", "All users have joined the room")
+ Log.v("#E2E TEST", "Alice is sending the message")
+
+ val text = "This is my message"
+ val sentEventId: String? = sendMessageInRoom(aliceRoomPOV, text)
+ // val sentEvent = testHelper.sendTextMessage(aliceRoomPOV, "Hello all", 1).first()
+ Assert.assertTrue("Message should be sent", sentEventId != null)
+
+ // All should be able to decrypt
+ otherAccounts.forEach { otherSession ->
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = otherSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId!!)
+ timelineEvent != null &&
+ timelineEvent.isEncrypted() &&
+ timelineEvent.root.getClearType() == EventType.MESSAGE
+ }
+ }
+ }
+
+ // Add a new user to the room, and check that he can't decrypt
+ val newAccount = listOf("adam") // , "adam", "manu")
+ .map {
+ testHelper.createAccount(it, SessionTestParams(true))
+ }
+
+ newAccount.forEach {
+ testHelper.runBlockingTest {
+ Log.v("#E2E TEST", "Alice invites ${it.myUserId}")
+ aliceRoomPOV.invite(it.myUserId)
+ }
+ }
+
+ newAccount.forEach {
+ waitForAndAcceptInviteInRoom(it, e2eRoomID)
+ }
+
+ ensureMembersHaveJoined(aliceSession, newAccount, e2eRoomID)
+
+ // wait a bit
+ testHelper.runBlockingTest {
+ delay(3_000)
+ }
+
+ // check that messages are encrypted (uisi)
+ newAccount.forEach { otherSession ->
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = otherSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId!!).also {
+ Log.v("#E2E TEST", "Event seen by new user ${it?.root?.getClearType()}|${it?.root?.mCryptoError}")
+ }
+ timelineEvent != null &&
+ timelineEvent.root.getClearType() == EventType.ENCRYPTED &&
+ timelineEvent.root.mCryptoError == MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID
+ }
+ }
+ }
+
+ // Let alice send a new message
+ Log.v("#E2E TEST", "Alice sends a new message")
+
+ val secondMessage = "2 This is my message"
+ val secondSentEventId: String? = sendMessageInRoom(aliceRoomPOV, secondMessage)
+
+ // new members should be able to decrypt it
+ newAccount.forEach { otherSession ->
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = otherSession.getRoom(e2eRoomID)?.getTimelineEvent(secondSentEventId!!).also {
+ Log.v("#E2E TEST", "Second Event seen by new user ${it?.root?.getClearType()}|${it?.root?.mCryptoError}")
+ }
+ timelineEvent != null &&
+ timelineEvent.root.getClearType() == EventType.MESSAGE &&
+ secondMessage == timelineEvent.root.getClearContent().toModel()?.body
+ }
+ }
+ }
+
+ otherAccounts.forEach {
+ testHelper.signOutAndClose(it)
+ }
+ newAccount.forEach { testHelper.signOutAndClose(it) }
+
+ cryptoTestData.cleanUp(testHelper)
+ }
+
+ /**
+ * Quick test for basic key backup
+ * 1. Create e2e between Alice and Bob
+ * 2. Alice sends 3 messages, using 3 different sessions
+ * 3. Ensure bob can decrypt
+ * 4. Create backup for bob and upload keys
+ *
+ * 5. Sign out alice and bob to ensure no gossiping will happen
+ *
+ * 6. Let bob sign in with a new session
+ * 7. Ensure history is UISI
+ * 8. Import backup
+ * 9. Check that new session can decrypt
+ */
+ @Test
+ fun testBasicBackupImport() {
+ val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
+ val aliceSession = cryptoTestData.firstSession
+ val bobSession = cryptoTestData.secondSession!!
+ val e2eRoomID = cryptoTestData.roomId
+
+ Log.v("#E2E TEST", "Create and start key backup for bob ...")
+ val bobKeysBackupService = bobSession.cryptoService().keysBackupService()
+ val keyBackupPassword = "FooBarBaz"
+ val megolmBackupCreationInfo = testHelper.doSync {
+ bobKeysBackupService.prepareKeysBackupVersion(keyBackupPassword, null, it)
+ }
+ val version = testHelper.doSync {
+ bobKeysBackupService.createKeysBackupVersion(megolmBackupCreationInfo, it)
+ }
+ Log.v("#E2E TEST", "... Key backup started and enabled for bob")
+ // Bob session should now have
+
+ val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!!
+
+ // let's send a few message to bob
+ val sentEventIds = mutableListOf()
+ val messagesText = listOf("1. Hello", "2. Bob", "3. Good morning")
+ messagesText.forEach { text ->
+ val sentEventId = sendMessageInRoom(aliceRoomPOV, text)!!.also {
+ sentEventIds.add(it)
+ }
+
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId)
+ timelineEvent != null &&
+ timelineEvent.isEncrypted() &&
+ timelineEvent.root.getClearType() == EventType.MESSAGE
+ }
+ }
+ // we want more so let's discard the session
+ aliceSession.cryptoService().discardOutboundSession(e2eRoomID)
+
+ testHelper.runBlockingTest {
+ delay(1_000)
+ }
+ }
+ Log.v("#E2E TEST", "Bob received all and can decrypt")
+
+ // Let's wait a bit to be sure that bob has backed up the session
+
+ Log.v("#E2E TEST", "Force key backup for Bob...")
+ testHelper.waitWithLatch { latch ->
+ bobKeysBackupService.backupAllGroupSessions(
+ null,
+ TestMatrixCallback(latch, true)
+ )
+ }
+ Log.v("#E2E TEST", "... Key backup done for Bob")
+
+ // Now lets logout both alice and bob to ensure that we won't have any gossiping
+
+ val bobUserId = bobSession.myUserId
+ Log.v("#E2E TEST", "Logout alice and bob...")
+ testHelper.signOutAndClose(aliceSession)
+ testHelper.signOutAndClose(bobSession)
+ Log.v("#E2E TEST", "..Logout alice and bob...")
+
+ testHelper.runBlockingTest {
+ delay(1_000)
+ }
+
+ // Create a new session for bob
+ Log.v("#E2E TEST", "Create a new session for Bob")
+ val newBobSession = testHelper.logIntoAccount(bobUserId, SessionTestParams(true))
+
+ // check that bob can't currently decrypt
+ Log.v("#E2E TEST", "check that bob can't currently decrypt")
+ sentEventIds.forEach { sentEventId ->
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId)?.also {
+ Log.v("#E2E TEST", "Event seen by new user ${it.root.getClearType()}|${it.root.mCryptoError}")
+ }
+ timelineEvent != null &&
+ timelineEvent.root.getClearType() == EventType.ENCRYPTED
+ }
+ }
+ }
+ // after initial sync events are not decrypted, so we have to try manually
+ ensureCannotDecrypt(sentEventIds, newBobSession, e2eRoomID, MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID)
+
+ // Let's now import keys from backup
+
+ newBobSession.cryptoService().keysBackupService().let { keysBackupService ->
+ val keyVersionResult = testHelper.doSync {
+ keysBackupService.getVersion(version.version, it)
+ }
+
+ val importedResult = testHelper.doSync {
+ keysBackupService.restoreKeyBackupWithPassword(keyVersionResult!!,
+ keyBackupPassword,
+ null,
+ null,
+ null, it)
+ }
+
+ assertEquals(3, importedResult.totalNumberOfKeys)
+ }
+
+ // ensure bob can now decrypt
+ ensureCanDecrypt(sentEventIds, newBobSession, e2eRoomID, messagesText)
+
+ testHelper.signOutAndClose(newBobSession)
+ }
+
+ /**
+ * Check that a new verified session that was not supposed to get the keys initially will
+ * get them from an older one.
+ */
+ @Test
+ fun testSimpleGossip() {
+ val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
+ val aliceSession = cryptoTestData.firstSession
+ val bobSession = cryptoTestData.secondSession!!
+ val e2eRoomID = cryptoTestData.roomId
+
+ val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!!
+
+ cryptoTestHelper.initializeCrossSigning(bobSession)
+
+ // let's send a few message to bob
+ val sentEventIds = mutableListOf()
+ val messagesText = listOf("1. Hello", "2. Bob")
+
+ Log.v("#E2E TEST", "Alice sends some messages")
+ messagesText.forEach { text ->
+ val sentEventId = sendMessageInRoom(aliceRoomPOV, text)!!.also {
+ sentEventIds.add(it)
+ }
+
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId)
+ timelineEvent != null &&
+ timelineEvent.isEncrypted() &&
+ timelineEvent.root.getClearType() == EventType.MESSAGE
+ }
+ }
+ }
+
+ // Ensure bob can decrypt
+ ensureIsDecrypted(sentEventIds, bobSession, e2eRoomID)
+
+ // Let's now add a new bob session
+ // Create a new session for bob
+ Log.v("#E2E TEST", "Create a new session for Bob")
+ val newBobSession = testHelper.logIntoAccount(bobSession.myUserId, SessionTestParams(true))
+
+ // check that new bob can't currently decrypt
+ Log.v("#E2E TEST", "check that new bob can't currently decrypt")
+
+ ensureCannotDecrypt(sentEventIds, newBobSession, e2eRoomID, MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID)
+
+ // Try to request
+ sentEventIds.forEach { sentEventId ->
+ val event = newBobSession.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root
+ newBobSession.cryptoService().requestRoomKeyForEvent(event)
+ }
+
+ // wait a bit
+ testHelper.runBlockingTest {
+ delay(10_000)
+ }
+
+ // Ensure that new bob still can't decrypt (keys must have been withheld)
+ ensureCannotDecrypt(sentEventIds, newBobSession, e2eRoomID, MXCryptoError.ErrorType.KEYS_WITHHELD)
+
+ // Now mark new bob session as verified
+
+ bobSession.cryptoService().verificationService().markedLocallyAsManuallyVerified(newBobSession.myUserId, newBobSession.sessionParams.deviceId!!)
+ newBobSession.cryptoService().verificationService().markedLocallyAsManuallyVerified(bobSession.myUserId, bobSession.sessionParams.deviceId!!)
+
+ // now let new session re-request
+ sentEventIds.forEach { sentEventId ->
+ val event = newBobSession.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root
+ newBobSession.cryptoService().reRequestRoomKeyForEvent(event)
+ }
+
+ // wait a bit
+ testHelper.runBlockingTest {
+ delay(10_000)
+ }
+
+ ensureCanDecrypt(sentEventIds, newBobSession, e2eRoomID, messagesText)
+
+ cryptoTestData.cleanUp(testHelper)
+ testHelper.signOutAndClose(newBobSession)
+ }
+
+ /**
+ * Test that if a better key is forwarded (lower index, it is then used)
+ */
+ @Test
+ fun testForwardBetterKey() {
+ val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
+ val aliceSession = cryptoTestData.firstSession
+ val bobSessionWithBetterKey = cryptoTestData.secondSession!!
+ val e2eRoomID = cryptoTestData.roomId
+
+ val aliceRoomPOV = aliceSession.getRoom(e2eRoomID)!!
+
+ cryptoTestHelper.initializeCrossSigning(bobSessionWithBetterKey)
+
+ // let's send a few message to bob
+ var firstEventId: String
+ val firstMessage = "1. Hello"
+
+ Log.v("#E2E TEST", "Alice sends some messages")
+ firstMessage.let { text ->
+ firstEventId = sendMessageInRoom(aliceRoomPOV, text)!!
+
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = bobSessionWithBetterKey.getRoom(e2eRoomID)?.getTimelineEvent(firstEventId)
+ timelineEvent != null &&
+ timelineEvent.isEncrypted() &&
+ timelineEvent.root.getClearType() == EventType.MESSAGE
+ }
+ }
+ }
+
+ // Ensure bob can decrypt
+ ensureIsDecrypted(listOf(firstEventId), bobSessionWithBetterKey, e2eRoomID)
+
+ // Let's add a new unverified session from bob
+ val newBobSession = testHelper.logIntoAccount(bobSessionWithBetterKey.myUserId, SessionTestParams(true))
+
+ // check that new bob can't currently decrypt
+ Log.v("#E2E TEST", "check that new bob can't currently decrypt")
+ ensureCannotDecrypt(listOf(firstEventId), newBobSession, e2eRoomID, MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID)
+
+ // Now let alice send a new message. this time the new bob session will be able to decrypt
+ var secondEventId: String
+ val secondMessage = "2. New Device?"
+
+ Log.v("#E2E TEST", "Alice sends some messages")
+ secondMessage.let { text ->
+ secondEventId = sendMessageInRoom(aliceRoomPOV, text)!!
+
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(secondEventId)
+ timelineEvent != null &&
+ timelineEvent.isEncrypted() &&
+ timelineEvent.root.getClearType() == EventType.MESSAGE
+ }
+ }
+ }
+
+ // check that both messages have same sessionId (it's just that we don't have index 0)
+ val firstEventNewBobPov = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(firstEventId)
+ val secondEventNewBobPov = newBobSession.getRoom(e2eRoomID)?.getTimelineEvent(secondEventId)
+
+ val firstSessionId = firstEventNewBobPov!!.root.content.toModel()!!.sessionId!!
+ val secondSessionId = secondEventNewBobPov!!.root.content.toModel()!!.sessionId!!
+
+ Assert.assertTrue("Should be the same session id", firstSessionId == secondSessionId)
+
+ // Confirm we can decrypt one but not the other
+ testHelper.runBlockingTest {
+ try {
+ newBobSession.cryptoService().decryptEvent(firstEventNewBobPov.root, "")
+ fail("Should not be able to decrypt event")
+ } catch (error: MXCryptoError) {
+ val errorType = (error as? MXCryptoError.Base)?.errorType
+ assertEquals(MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX, errorType)
+ }
+ }
+
+ testHelper.runBlockingTest {
+ try {
+ newBobSession.cryptoService().decryptEvent(secondEventNewBobPov.root, "")
+ } catch (error: MXCryptoError) {
+ fail("Should be able to decrypt event")
+ }
+ }
+
+ // Now let's verify bobs session, and re-request keys
+ bobSessionWithBetterKey.cryptoService()
+ .verificationService()
+ .markedLocallyAsManuallyVerified(newBobSession.myUserId, newBobSession.sessionParams.deviceId!!)
+
+ newBobSession.cryptoService()
+ .verificationService()
+ .markedLocallyAsManuallyVerified(bobSessionWithBetterKey.myUserId, bobSessionWithBetterKey.sessionParams.deviceId!!)
+
+ // now let new session request
+ newBobSession.cryptoService().requestRoomKeyForEvent(firstEventNewBobPov.root)
+
+ // wait a bit
+ testHelper.runBlockingTest {
+ delay(10_000)
+ }
+
+ // old session should have shared the key at earliest known index now
+ // we should be able to decrypt both
+ testHelper.runBlockingTest {
+ try {
+ newBobSession.cryptoService().decryptEvent(firstEventNewBobPov.root, "")
+ } catch (error: MXCryptoError) {
+ fail("Should be able to decrypt first event now $error")
+ }
+ }
+ testHelper.runBlockingTest {
+ try {
+ newBobSession.cryptoService().decryptEvent(secondEventNewBobPov.root, "")
+ } catch (error: MXCryptoError) {
+ fail("Should be able to decrypt event $error")
+ }
+ }
+
+ cryptoTestData.cleanUp(testHelper)
+ testHelper.signOutAndClose(newBobSession)
+ }
+
+ private fun sendMessageInRoom(aliceRoomPOV: Room, text: String): String? {
+ aliceRoomPOV.sendTextMessage(text)
+ var sentEventId: String? = null
+ testHelper.waitWithLatch(4 * TestConstants.timeOutMillis) { latch ->
+ val timeline = aliceRoomPOV.createTimeline(null, TimelineSettings(60))
+ timeline.start()
+
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val decryptedMsg = timeline.getSnapshot()
+ .filter { it.root.getClearType() == EventType.MESSAGE }
+ .also { list ->
+ val message = list.joinToString(",", "[", "]") { "${it.root.type}|${it.root.sendState}" }
+ Log.v("#E2E TEST", "Timeline snapshot is $message")
+ }
+ .filter { it.root.sendState == SendState.SYNCED }
+ .firstOrNull { it.root.getClearContent().toModel()?.body?.startsWith(text) == true }
+ sentEventId = decryptedMsg?.eventId
+ decryptedMsg != null
+ }
+
+ timeline.dispose()
+ }
+ return sentEventId
+ }
+
+ private fun ensureMembersHaveJoined(aliceSession: Session, otherAccounts: List, e2eRoomID: String) {
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ otherAccounts.map {
+ aliceSession.getRoomMember(it.myUserId, e2eRoomID)?.membership
+ }.all {
+ it == Membership.JOIN
+ }
+ }
+ }
+ }
+
+ private fun waitForAndAcceptInviteInRoom(otherSession: Session, e2eRoomID: String) {
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val roomSummary = otherSession.getRoomSummary(e2eRoomID)
+ (roomSummary != null && roomSummary.membership == Membership.INVITE).also {
+ if (it) {
+ Log.v("#E2E TEST", "${otherSession.myUserId} can see the invite from alice")
+ }
+ }
+ }
+ }
+
+ testHelper.runBlockingTest(60_000) {
+ Log.v("#E2E TEST", "${otherSession.myUserId} tries to join room $e2eRoomID")
+ try {
+ otherSession.joinRoom(e2eRoomID)
+ } catch (ex: JoinRoomFailure.JoinedWithTimeout) {
+ // it's ok we will wait after
+ }
+ }
+
+ Log.v("#E2E TEST", "${otherSession.myUserId} waiting for join echo ...")
+ testHelper.waitWithLatch {
+ testHelper.retryPeriodicallyWithLatch(it) {
+ val roomSummary = otherSession.getRoomSummary(e2eRoomID)
+ roomSummary != null && roomSummary.membership == Membership.JOIN
+ }
+ }
+ }
+
+ private fun ensureCanDecrypt(sentEventIds: MutableList, session: Session, e2eRoomID: String, messagesText: List) {
+ sentEventIds.forEachIndexed { index, sentEventId ->
+ testHelper.waitWithLatch { latch ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val event = session.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root
+ testHelper.runBlockingTest {
+ try {
+ session.cryptoService().decryptEvent(event, "").let { result ->
+ event.mxDecryptionResult = OlmDecryptionResult(
+ payload = result.clearEvent,
+ senderKey = result.senderCurve25519Key,
+ keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
+ forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
+ )
+ }
+ } catch (error: MXCryptoError) {
+ // nop
+ }
+ }
+ event.getClearType() == EventType.MESSAGE &&
+ messagesText[index] == event.getClearContent()?.toModel()?.body
+ }
+ }
+ }
+ }
+
+ private fun ensureIsDecrypted(sentEventIds: List, session: Session, e2eRoomID: String) {
+ testHelper.waitWithLatch { latch ->
+ sentEventIds.forEach { sentEventId ->
+ testHelper.retryPeriodicallyWithLatch(latch) {
+ val timelineEvent = session.getRoom(e2eRoomID)?.getTimelineEvent(sentEventId)
+ timelineEvent != null &&
+ timelineEvent.isEncrypted() &&
+ timelineEvent.root.getClearType() == EventType.MESSAGE
+ }
+ }
+ }
+ }
+
+ private fun ensureCannotDecrypt(sentEventIds: List, newBobSession: Session, e2eRoomID: String, expectedError: MXCryptoError.ErrorType?) {
+ sentEventIds.forEach { sentEventId ->
+ val event = newBobSession.getRoom(e2eRoomID)!!.getTimelineEvent(sentEventId)!!.root
+ testHelper.runBlockingTest {
+ try {
+ newBobSession.cryptoService().decryptEvent(event, "")
+ fail("Should not be able to decrypt event")
+ } catch (error: MXCryptoError) {
+ val errorType = (error as? MXCryptoError.Base)?.errorType
+ if (expectedError == null) {
+ Assert.assertNotNull(errorType)
+ } else {
+ assertEquals(expectedError, errorType, "Message expected to be UISI")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt
index a7a81bacf5..46c1dacf78 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt
@@ -21,7 +21,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.FixMethodOrder
-import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@@ -41,7 +40,6 @@ class PreShareKeysTest : InstrumentedTest {
private val cryptoTestHelper = CryptoTestHelper(testHelper)
@Test
- @Ignore("This test will be ignored until it is fixed")
fun ensure_outbound_session_happy_path() {
val testData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
val e2eRoomID = testData.roomId
@@ -92,7 +90,7 @@ class PreShareKeysTest : InstrumentedTest {
// Just send a real message as test
val sentEvent = testHelper.sendTextMessage(aliceSession.getRoom(e2eRoomID)!!, "Allo", 1).first()
- assertEquals(megolmSessionId, sentEvent.root.content.toModel()?.sessionId, "Unexpected megolm session")
+ assertEquals("Unexpected megolm session", megolmSessionId, sentEvent.root.content.toModel()?.sessionId,)
testHelper.waitWithLatch { latch ->
testHelper.retryPeriodicallyWithLatch(latch) {
bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEvent.eventId)?.root?.getClearType() == EventType.MESSAGE
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt
index 0a8ce67680..fb5d58b127 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/UnwedgingTest.kt
@@ -21,7 +21,6 @@ import org.amshove.kluent.shouldBe
import org.junit.Assert
import org.junit.Before
import org.junit.FixMethodOrder
-import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@@ -85,7 +84,6 @@ class UnwedgingTest : InstrumentedTest {
* -> This is automatically fixed after SDKs restarted the olm session
*/
@Test
- @Ignore("This test will be ignored until it is fixed")
fun testUnwedging() {
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom()
@@ -94,9 +92,7 @@ class UnwedgingTest : InstrumentedTest {
val bobSession = cryptoTestData.secondSession!!
val aliceCryptoStore = (aliceSession.cryptoService() as DefaultCryptoService).cryptoStoreForTesting
-
- // bobSession.cryptoService().setWarnOnUnknownDevices(false)
- // aliceSession.cryptoService().setWarnOnUnknownDevices(false)
+ val olmDevice = (aliceSession.cryptoService() as DefaultCryptoService).olmDeviceForTest
val roomFromBobPOV = bobSession.getRoom(aliceRoomId)!!
val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!!
@@ -175,6 +171,7 @@ class UnwedgingTest : InstrumentedTest {
Timber.i("## CRYPTO | testUnwedging: wedge the session now. Set crypto state like after the first message")
aliceCryptoStore.storeSession(OlmSessionWrapper(deserializeFromRealm(oldSession)!!), bobSession.cryptoService().getMyDevice().identityKey()!!)
+ olmDevice.clearOlmSessionCache()
Thread.sleep(6_000)
// Force new session, and key share
@@ -227,8 +224,10 @@ class UnwedgingTest : InstrumentedTest {
testHelper.waitWithLatch {
testHelper.retryPeriodicallyWithLatch(it) {
// we should get back the key and be able to decrypt
- val result = tryOrNull {
- bobSession.cryptoService().decryptEvent(messagesReceivedByBob[0].root, "")
+ val result = testHelper.runBlockingTest {
+ tryOrNull {
+ bobSession.cryptoService().decryptEvent(messagesReceivedByBob[0].root, "")
+ }
}
Timber.i("## CRYPTO | testUnwedging: decrypt result ${result?.clearEvent}")
result != null
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt
index 82aee454eb..cd20ab477c 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/KeyShareTests.kt
@@ -97,7 +97,9 @@ class KeyShareTests : InstrumentedTest {
assert(receivedEvent!!.isEncrypted())
try {
- aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
+ commonTestHelper.runBlockingTest {
+ aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
+ }
fail("should fail")
} catch (failure: Throwable) {
}
@@ -152,7 +154,9 @@ class KeyShareTests : InstrumentedTest {
}
try {
- aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
+ commonTestHelper.runBlockingTest {
+ aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
+ }
fail("should fail")
} catch (failure: Throwable) {
}
@@ -189,7 +193,9 @@ class KeyShareTests : InstrumentedTest {
}
try {
- aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
+ commonTestHelper.runBlockingTest {
+ aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
+ }
} catch (failure: Throwable) {
fail("should have been able to decrypt")
}
@@ -384,7 +390,11 @@ class KeyShareTests : InstrumentedTest {
val roomRoomBobPov = aliceSession.getRoom(roomId)
val beforeJoin = roomRoomBobPov!!.getTimelineEvent(secondEventId)
- var dRes = tryOrNull { bobSession.cryptoService().decryptEvent(beforeJoin!!.root, "") }
+ var dRes = tryOrNull {
+ commonTestHelper.runBlockingTest {
+ bobSession.cryptoService().decryptEvent(beforeJoin!!.root, "")
+ }
+ }
assert(dRes == null)
@@ -395,7 +405,11 @@ class KeyShareTests : InstrumentedTest {
Thread.sleep(3_000)
// With the bug the first session would have improperly reshare that key :/
- dRes = tryOrNull { bobSession.cryptoService().decryptEvent(beforeJoin.root, "") }
+ dRes = tryOrNull {
+ commonTestHelper.runBlockingTest {
+ bobSession.cryptoService().decryptEvent(beforeJoin.root, "")
+ }
+ }
Log.d("#TEST", "KS: sgould not decrypt that ${beforeJoin.root.getClearContent().toModel()?.body}")
assert(dRes?.clearEvent == null)
}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt
index 9fda21763a..65c65660b5 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt
@@ -93,7 +93,9 @@ class WithHeldTests : InstrumentedTest {
// Bob should not be able to decrypt because the keys is withheld
try {
// .. might need to wait a bit for stability?
- bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
+ testHelper.runBlockingTest {
+ bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
+ }
Assert.fail("This session should not be able to decrypt")
} catch (failure: Throwable) {
val type = (failure as MXCryptoError.Base).errorType
@@ -118,7 +120,9 @@ class WithHeldTests : InstrumentedTest {
// Previous message should still be undecryptable (partially withheld session)
try {
// .. might need to wait a bit for stability?
- bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
+ testHelper.runBlockingTest {
+ bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
+ }
Assert.fail("This session should not be able to decrypt")
} catch (failure: Throwable) {
val type = (failure as MXCryptoError.Base).errorType
@@ -165,7 +169,9 @@ class WithHeldTests : InstrumentedTest {
val eventBobPOV = bobSession.getRoom(testData.roomId)?.getTimelineEvent(eventId)
try {
// .. might need to wait a bit for stability?
- bobSession.cryptoService().decryptEvent(eventBobPOV!!.root, "")
+ testHelper.runBlockingTest {
+ bobSession.cryptoService().decryptEvent(eventBobPOV!!.root, "")
+ }
Assert.fail("This session should not be able to decrypt")
} catch (failure: Throwable) {
val type = (failure as MXCryptoError.Base).errorType
@@ -233,7 +239,11 @@ class WithHeldTests : InstrumentedTest {
testHelper.retryPeriodicallyWithLatch(latch) {
val timeLineEvent = bobSecondSession.getRoom(testData.roomId)?.getTimelineEvent(eventId)?.also {
// try to decrypt and force key request
- tryOrNull { bobSecondSession.cryptoService().decryptEvent(it.root, "") }
+ tryOrNull {
+ testHelper.runBlockingTest {
+ bobSecondSession.cryptoService().decryptEvent(it.root, "")
+ }
+ }
}
sessionId = timeLineEvent?.root?.content?.toModel()?.sessionId
timeLineEvent != null
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt
index 35c5a4dab9..2c96568102 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/VerificationTest.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.crypto.verification.qrcode
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.amshove.kluent.shouldBe
import org.junit.FixMethodOrder
+import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
@@ -39,6 +40,7 @@ import kotlin.coroutines.resume
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.JVM)
+@Ignore("This test is flaky ; see issue #5449")
class VerificationTest : InstrumentedTest {
data class ExpectedResult(
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt
index 9856ee7770..1e3512a9df 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParserTest.kt
@@ -60,7 +60,9 @@ class MarkdownParserTest : InstrumentedTest {
applicationFlavor = "TestFlavor",
roomDisplayNameFallbackProvider = TestRoomDisplayNameFallbackProvider()
)
- ))
+ ),
+ TestPermalinkService()
+ )
)
@Test
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/TestPermalinkService.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/TestPermalinkService.kt
new file mode 100644
index 0000000000..2f9a5e0a73
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/room/send/TestPermalinkService.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.room.send
+
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.permalinks.PermalinkService
+import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.HTML
+import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.MARKDOWN
+
+class TestPermalinkService : PermalinkService {
+ override fun createPermalink(event: Event, forceMatrixTo: Boolean): String? {
+ return null
+ }
+
+ override fun createPermalink(id: String, forceMatrixTo: Boolean): String? {
+ return ""
+ }
+
+ override fun createPermalink(roomId: String, eventId: String, forceMatrixTo: Boolean): String {
+ return ""
+ }
+
+ override fun createRoomPermalink(roomId: String, viaServers: List?, forceMatrixTo: Boolean): String? {
+ return null
+ }
+
+ override fun getLinkedId(url: String): String? {
+ return null
+ }
+
+ override fun createMentionSpanTemplate(type: PermalinkService.SpanTemplateType, forceMatrixTo: Boolean): String {
+ return when (type) {
+ HTML -> "%2\$s"
+ MARKDOWN -> "[%2\$s](https://matrix.to/#/%1\$s)"
+ }
+ }
+}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt
index 69ae57e644..5c011c8b2f 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt
@@ -62,7 +62,11 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it)
}
- chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap())
+ chunk.addTimelineEvent(
+ roomId = ROOM_ID,
+ eventEntity = fakeEvent,
+ direction = PaginationDirection.FORWARDS,
+ roomMemberContentsByUser = emptyMap())
chunk.timelineEvents.size shouldBeEqualTo 1
}
}
@@ -74,8 +78,16 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it)
}
- chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap())
- chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap())
+ chunk.addTimelineEvent(
+ roomId = ROOM_ID,
+ eventEntity = fakeEvent,
+ direction = PaginationDirection.FORWARDS,
+ roomMemberContentsByUser = emptyMap())
+ chunk.addTimelineEvent(
+ roomId = ROOM_ID,
+ eventEntity = fakeEvent,
+ direction = PaginationDirection.FORWARDS,
+ roomMemberContentsByUser = emptyMap())
chunk.timelineEvents.size shouldBeEqualTo 1
}
}
@@ -144,7 +156,11 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it)
}
- addTimelineEvent(roomId, fakeEvent, direction, emptyMap())
+ addTimelineEvent(
+ roomId = roomId,
+ eventEntity = fakeEvent,
+ direction = direction,
+ roomMemberContentsByUser = emptyMap())
}
}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
new file mode 100644
index 0000000000..6792d6ddfd
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
@@ -0,0 +1,233 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.session.room.timeline
+
+import org.amshove.kluent.fail
+import org.amshove.kluent.shouldBe
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldBeGreaterThan
+import org.amshove.kluent.shouldContain
+import org.amshove.kluent.shouldContainAll
+import org.junit.FixMethodOrder
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.junit.runners.MethodSorters
+import org.matrix.android.sdk.InstrumentedTest
+import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.events.model.toModel
+import org.matrix.android.sdk.api.session.room.model.PollResponseAggregatedSummary
+import org.matrix.android.sdk.api.session.room.model.PollSummaryContent
+import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
+import org.matrix.android.sdk.api.session.room.model.message.PollType
+import org.matrix.android.sdk.api.session.room.timeline.Timeline
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
+import org.matrix.android.sdk.common.CommonTestHelper
+import org.matrix.android.sdk.common.CryptoTestHelper
+import java.util.concurrent.CountDownLatch
+
+@RunWith(JUnit4::class)
+@FixMethodOrder(MethodSorters.JVM)
+class PollAggregationTest : InstrumentedTest {
+
+ @Test
+ fun testAllPollUseCases() {
+ val commonTestHelper = CommonTestHelper(context())
+ val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
+ val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false)
+
+ val aliceSession = cryptoTestData.firstSession
+ val aliceRoomId = cryptoTestData.roomId
+ val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!!
+
+ val roomFromBobPOV = cryptoTestData.secondSession!!.getRoom(cryptoTestData.roomId)!!
+ // Bob creates a poll
+ roomFromBobPOV.sendPoll(PollType.DISCLOSED, pollQuestion, pollOptions)
+
+ aliceSession.startSync(true)
+ val aliceTimeline = roomFromAlicePOV.createTimeline(null, TimelineSettings(30))
+ aliceTimeline.start()
+
+ val TOTAL_TEST_COUNT = 7
+ val lock = CountDownLatch(TOTAL_TEST_COUNT)
+
+ val aliceEventsListener = object : Timeline.Listener {
+ override fun onTimelineUpdated(snapshot: List) {
+ snapshot.firstOrNull { it.root.getClearType() in EventType.POLL_START }?.let { pollEvent ->
+ val pollEventId = pollEvent.eventId
+ val pollContent = pollEvent.root.content?.toModel()
+ val pollSummary = pollEvent.annotations?.pollResponseSummary
+
+ if (pollContent == null) {
+ fail("Poll content is null")
+ return
+ }
+
+ when (lock.count.toInt()) {
+ TOTAL_TEST_COUNT -> {
+ // Poll has just been created.
+ testInitialPollConditions(pollContent, pollSummary)
+ lock.countDown()
+ roomFromBobPOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.firstOrNull()?.id ?: "")
+ }
+ TOTAL_TEST_COUNT - 1 -> {
+ // Bob: Option 1
+ testBobVotesOption1(pollContent, pollSummary)
+ lock.countDown()
+ roomFromBobPOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id ?: "")
+ }
+ TOTAL_TEST_COUNT - 2 -> {
+ // Bob: Option 2
+ testBobChangesVoteToOption2(pollContent, pollSummary)
+ lock.countDown()
+ roomFromAlicePOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id ?: "")
+ }
+ TOTAL_TEST_COUNT - 3 -> {
+ // Alice: Option 2, Bob: Option 2
+ testAliceAndBobVoteToOption2(pollContent, pollSummary)
+ lock.countDown()
+ roomFromAlicePOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.firstOrNull()?.id ?: "")
+ }
+ TOTAL_TEST_COUNT - 4 -> {
+ // Alice: Option 1, Bob: Option 2
+ testAliceVotesOption1AndBobVotesOption2(pollContent, pollSummary)
+ lock.countDown()
+ roomFromBobPOV.endPoll(pollEventId)
+ }
+ TOTAL_TEST_COUNT - 5 -> {
+ // Alice: Option 1, Bob: Option 2 [poll is ended]
+ testEndedPoll(pollSummary)
+ lock.countDown()
+ roomFromAlicePOV.voteToPoll(pollEventId, pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id ?: "")
+ }
+ TOTAL_TEST_COUNT - 6 -> {
+ // Alice: Option 1 (ignore change), Bob: Option 2 [poll is ended]
+ testAliceVotesOption1AndBobVotesOption2(pollContent, pollSummary)
+ testEndedPoll(pollSummary)
+ lock.countDown()
+ }
+ else -> {
+ fail("Lock count ${lock.count} didn't handled.")
+ }
+ }
+ }
+ }
+ }
+
+ aliceTimeline.addListener(aliceEventsListener)
+
+ commonTestHelper.await(lock)
+
+ aliceTimeline.removeAllListeners()
+
+ aliceSession.stopSync()
+ aliceTimeline.dispose()
+ cryptoTestData.cleanUp(commonTestHelper)
+ }
+
+ private fun testInitialPollConditions(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) {
+ // No votes yet, poll summary should be null
+ pollSummary shouldBe null
+ // Question should be the same as intended
+ pollContent.getBestPollCreationInfo()?.question?.getBestQuestion() shouldBeEqualTo pollQuestion
+ // Options should be the same as intended
+ pollContent.getBestPollCreationInfo()?.answers?.let { answers ->
+ answers.size shouldBeEqualTo pollOptions.size
+ answers.map { it.getBestAnswer() } shouldContainAll pollOptions
+ }
+ }
+
+ private fun testBobVotesOption1(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) {
+ if (pollSummary == null) {
+ fail("Poll summary shouldn't be null when someone votes")
+ return
+ }
+ val answerId = pollContent.getBestPollCreationInfo()?.answers?.first()?.id
+ // Check if the intended vote is in poll summary
+ pollSummary.aggregatedContent?.let { aggregatedContent ->
+ assertTotalVotesCount(aggregatedContent, 1)
+ aggregatedContent.votes?.first()?.option shouldBeEqualTo answerId
+ aggregatedContent.votesSummary?.get(answerId)?.total shouldBeEqualTo 1
+ aggregatedContent.votesSummary?.get(answerId)?.percentage shouldBeEqualTo 1.0
+ } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") }
+ }
+
+ private fun testBobChangesVoteToOption2(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) {
+ if (pollSummary == null) {
+ fail("Poll summary shouldn't be null when someone votes")
+ return
+ }
+ val answerId = pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id
+ // Check if the intended vote is in poll summary
+ pollSummary.aggregatedContent?.let { aggregatedContent ->
+ assertTotalVotesCount(aggregatedContent, 1)
+ aggregatedContent.votes?.first()?.option shouldBeEqualTo answerId
+ aggregatedContent.votesSummary?.get(answerId)?.total shouldBeEqualTo 1
+ aggregatedContent.votesSummary?.get(answerId)?.percentage shouldBeEqualTo 1.0
+ } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") }
+ }
+
+ private fun testAliceAndBobVoteToOption2(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) {
+ if (pollSummary == null) {
+ fail("Poll summary shouldn't be null when someone votes")
+ return
+ }
+ val answerId = pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id
+ // Check if the intended votes is in poll summary
+ pollSummary.aggregatedContent?.let { aggregatedContent ->
+ assertTotalVotesCount(aggregatedContent, 2)
+ aggregatedContent.votes?.first()?.option shouldBeEqualTo answerId
+ aggregatedContent.votes?.get(1)?.option shouldBeEqualTo answerId
+ aggregatedContent.votesSummary?.get(answerId)?.total shouldBeEqualTo 2
+ aggregatedContent.votesSummary?.get(answerId)?.percentage shouldBeEqualTo 1.0
+ } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") }
+ }
+
+ private fun testAliceVotesOption1AndBobVotesOption2(pollContent: MessagePollContent, pollSummary: PollResponseAggregatedSummary?) {
+ if (pollSummary == null) {
+ fail("Poll summary shouldn't be null when someone votes")
+ return
+ }
+ val firstAnswerId = pollContent.getBestPollCreationInfo()?.answers?.firstOrNull()?.id
+ val secondAnswerId = pollContent.getBestPollCreationInfo()?.answers?.get(1)?.id
+ // Check if the intended votes is in poll summary
+ pollSummary.aggregatedContent?.let { aggregatedContent ->
+ assertTotalVotesCount(aggregatedContent, 2)
+ aggregatedContent.votes!!.map { it.option } shouldContain firstAnswerId
+ aggregatedContent.votes!!.map { it.option } shouldContain secondAnswerId
+ aggregatedContent.votesSummary?.get(firstAnswerId)?.total shouldBeEqualTo 1
+ aggregatedContent.votesSummary?.get(secondAnswerId)?.total shouldBeEqualTo 1
+ aggregatedContent.votesSummary?.get(firstAnswerId)?.percentage shouldBeEqualTo 0.5
+ aggregatedContent.votesSummary?.get(secondAnswerId)?.percentage shouldBeEqualTo 0.5
+ } ?: run { fail("Aggregated poll content shouldn't be null after someone votes") }
+ }
+
+ private fun testEndedPoll(pollSummary: PollResponseAggregatedSummary?) {
+ pollSummary?.closedTime ?: 0 shouldBeGreaterThan 0
+ }
+
+ private fun assertTotalVotesCount(aggregatedContent: PollSummaryContent, expectedVoteCount: Int) {
+ aggregatedContent.totalVotes shouldBeEqualTo expectedVoteCount
+ aggregatedContent.votes?.size shouldBeEqualTo expectedVoteCount
+ }
+
+ companion object {
+ const val pollQuestion = "Do you like creating polls?"
+ val pollOptions = listOf("Yes", "Absolutely", "As long as tests pass")
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt
index c87f21d7ac..c4bc289b75 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt
@@ -60,7 +60,15 @@ data class MatrixConfiguration(
/**
* RoomDisplayNameFallbackProvider to provide default room display name.
*/
- val roomDisplayNameFallbackProvider: RoomDisplayNameFallbackProvider
+ val roomDisplayNameFallbackProvider: RoomDisplayNameFallbackProvider,
+ /**
+ * True to enable presence information sync (if available). False to disable regardless of server setting.
+ */
+ val presenceSyncEnabled: Boolean = true,
+ /**
+ * Thread messages default enable/disabled value
+ */
+ val threadMessagesEnabledDefault: Boolean = false,
) {
/**
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
index e3f00a24b6..65f69e17c9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
@@ -121,7 +121,7 @@ interface CryptoService {
fun discardOutboundSession(roomId: String)
@Throws(MXCryptoError::class)
- fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult
+ suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult
fun decryptEventAsync(event: Event, timeline: String, callback: MatrixCallback)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt
index 34096d603f..ae8ed3941f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt
@@ -49,5 +49,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AggregatedRelations(
@Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null,
- @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null
+ @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null,
+ @Json(name = RelationType.THREAD) val latestThread: LatestThreadUnsignedRelation? = null
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
index df57ca5681..f1304f6216 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
@@ -201,7 +201,11 @@ data class Event(
*/
fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted"
- val text = getDecryptedValue() ?: return null
+ val text = getDecryptedValue() ?: run {
+ if (isPoll()) { return getPollQuestion() ?: "created a poll." }
+ return null
+ }
+
return when {
isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
isFileMessage() -> "sent a file."
@@ -349,7 +353,7 @@ fun Event.isAttachmentMessage(): Boolean {
}
}
-fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END
+fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START || getClearType() in EventType.POLL_END
fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
@@ -372,7 +376,7 @@ fun Event.getRelationContent(): RelationDefaultContent? {
* Returns the poll question or null otherwise
*/
fun Event.getPollQuestion(): String? =
- getPollContent()?.pollCreationInfo?.question?.question
+ getPollContent()?.getBestPollCreationInfo()?.question?.getBestQuestion()
/**
* Returns the relation content for a specific type or null otherwise
@@ -385,12 +389,12 @@ fun Event.isReply(): Boolean {
}
fun Event.isReplyRenderedInThread(): Boolean {
- return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true
+ return isReply() && getRelationContent()?.shouldRenderInThread() == true
}
-fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null
+fun Event.isThread(): Boolean = getRelationContentForType(RelationType.THREAD)?.eventId != null
-fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_THREAD)?.eventId
+fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.THREAD)?.eventId
fun Event.isEdition(): Boolean {
return getRelationContentForType(RelationType.REPLACE)?.eventId != null
@@ -406,3 +410,5 @@ fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
fun Event.getPollContent(): MessagePollContent? {
return content.toModel()
}
+
+fun Event.supportsNotification() = this.getClearType() in EventType.MESSAGE + EventType.POLL_START
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
index 0c77b574e7..22fb9bcbe2 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
@@ -103,9 +103,9 @@ object EventType {
const val REACTION = "m.reaction"
// Poll
- const val POLL_START = "org.matrix.msc3381.poll.start"
- const val POLL_RESPONSE = "org.matrix.msc3381.poll.response"
- const val POLL_END = "org.matrix.msc3381.poll.end"
+ val POLL_START = listOf("org.matrix.msc3381.poll.start", "m.poll.start")
+ val POLL_RESPONSE = listOf("org.matrix.msc3381.poll.response", "m.poll.response")
+ val POLL_END = listOf("org.matrix.msc3381.poll.end", "m.poll.end")
// Unwedging
internal const val DUMMY = "m.dummy"
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt
new file mode 100644
index 0000000000..cc52dfc02c
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.events.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class LatestThreadUnsignedRelation(
+ override val limited: Boolean? = false,
+ override val count: Int? = 0,
+ @Json(name = "latest_event")
+ val event: Event? = null,
+ @Json(name = "current_user_participated")
+ val isUserParticipating: Boolean? = false
+
+) : UnsignedRelationInfo
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt
index fb26264ad7..74dc74b294 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt
@@ -30,7 +30,6 @@ object RelationType {
/** Lets you define an event which is a thread reply to an existing event.*/
const val THREAD = "m.thread"
- const val IO_THREAD = "io.element.thread"
/** Lets you define an event which adds a response to an existing event.*/
const val RESPONSE = "org.matrix.response"
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
index 2256dfb8f0..9db3876b74 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
@@ -50,7 +50,11 @@ data class HomeServerCapabilities(
* This capability describes the default and available room versions a server supports, and at what level of stability.
* Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms.
*/
- val roomVersions: RoomVersionCapabilities? = null
+ val roomVersions: RoomVersionCapabilities? = null,
+ /**
+ * True if the home server support threading
+ */
+ var canUseThreading: Boolean = false
) {
enum class RoomCapabilitySupport {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt
index 920dc85c7a..c139da813a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/permalinks/PermalinkService.kt
@@ -28,6 +28,11 @@ interface PermalinkService {
const val MATRIX_TO_URL_BASE = "https://matrix.to/#/"
}
+ enum class SpanTemplateType {
+ HTML,
+ MARKDOWN
+ }
+
/**
* Creates a permalink for an event.
* Ex: "https://matrix.to/#/!nbzmcXAqpxBXjAdgoX:matrix.org/$1531497316352799BevdV:matrix.org"
@@ -80,4 +85,15 @@ interface PermalinkService {
* @return the id from the url, ex: "@benoit:matrix.org", or null if the url is not a permalink
*/
fun getLinkedId(url: String): String?
+
+ /**
+ * Creates a HTML or Markdown mention span template. Can be used to replace a mention with a permalink to mentioned user.
+ * Ex: "%2\$s" or "[%2\$s](https://matrix.to/#/%1\$s)"
+ *
+ * @param type: type of template to create
+ * @param forceMatrixTo whether we should force using matrix.to base URL
+ *
+ * @return the created template
+ */
+ fun createMentionSpanTemplate(type: SpanTemplateType, forceMatrixTo: Boolean = false): String
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt
index d930a5d0fd..be65b883b3 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt
@@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService
import org.matrix.android.sdk.api.session.room.threads.ThreadsService
+import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService
import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@@ -47,6 +48,7 @@ import org.matrix.android.sdk.api.util.Optional
interface Room :
TimelineService,
ThreadsService,
+ ThreadsLocalService,
SendService,
DraftService,
ReadService,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt
index bca432320d..aec358218b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt
@@ -216,6 +216,12 @@ interface RoomService {
pagedListConfig: PagedList.Config = defaultPagedListConfig,
sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): UpdatableLivePageResult
+ /**
+ * Return a LiveData on the number of rooms
+ * @param queryParams parameters to query the room summaries. It can be use to keep only joined rooms, for instance.
+ */
+ fun getRoomCountLive(queryParams: RoomSummaryQueryParams): LiveData
+
/**
* TODO Doc
*/
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt
index b83f57f5ef..db87f913b9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt
@@ -22,10 +22,8 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
interface UpdatableLivePageResult {
val livePagedList: LiveData>
-
- fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams)
-
val liveBoundaries: LiveData
+ var queryParams: RoomSummaryQueryParams
}
data class ResultBoundaries(
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt
index e8b3cf2488..35fa555a5b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt
@@ -21,5 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LocationAsset(
- @Json(name = "type") val type: LocationAssetType? = null
+ @Json(name = "type") val type: String? = null
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt
index ef40e21c47..f7d82d4b40 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt
@@ -16,11 +16,20 @@
package org.matrix.android.sdk.api.session.room.model.message
-import com.squareup.moshi.Json
-import com.squareup.moshi.JsonClass
+/**
+ * Define what particular asset is being referred to.
+ * We don't use enum type since it is not limited to a specific set of values.
+ * The way this type should be interpreted in client side is described in
+ * [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md)
+ */
+object LocationAssetType {
+ /**
+ * Used for user location sharing.
+ **/
+ const val SELF = "m.self"
-@JsonClass(generateAdapter = false)
-enum class LocationAssetType {
- @Json(name = "m.self")
- SELF
+ /**
+ * Used for pin drop location sharing.
+ **/
+ const val PIN = "m.pin"
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt
index d07bd2d73a..2052133b06 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt
@@ -39,37 +39,47 @@ data class MessageLocationContent(
*/
@Json(name = "geo_uri") val geoUri: String,
- /**
- * See https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md
- */
- @Json(name = "org.matrix.msc3488.location") val locationInfo: LocationInfo? = null,
-
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
-
/**
- * m.asset defines a generic asset that can be used for location tracking but also in other places like
- * inventories, geofencing, checkins/checkouts etc.
- * It should contain a mandatory namespaced type key defining what particular asset is being referred to.
- * For the purposes of user location tracking m.self should be used in order to avoid duplicating the mxid.
+ * See [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md)
*/
- @Json(name = "m.asset") val locationAsset: LocationAsset? = null,
-
+ @Json(name = "org.matrix.msc3488.location") val unstableLocationInfo: LocationInfo? = null,
+ @Json(name = "m.location") val locationInfo: LocationInfo? = null,
/**
* Exact time that the data in the event refers to (milliseconds since the UNIX epoch)
*/
- @Json(name = "org.matrix.msc3488.ts") val ts: Long? = null,
-
- @Json(name = "org.matrix.msc1767.text") val text: String? = null
+ @Json(name = "org.matrix.msc3488.ts") val unstableTs: Long? = null,
+ @Json(name = "m.ts") val ts: Long? = null,
+ @Json(name = "org.matrix.msc1767.text") val unstableText: String? = null,
+ @Json(name = "m.text") val text: String? = null,
+ /**
+ * Defines a generic asset that can be used for location tracking but also in other places like
+ * inventories, geofencing, checkins/checkouts etc.
+ * It should contain a mandatory namespaced type key defining what particular asset is being referred to.
+ * For the purposes of user location tracking m.self should be used in order to avoid duplicating the mxid.
+ * See [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md)
+ */
+ @Json(name = "org.matrix.msc3488.asset") val unstableLocationAsset: LocationAsset? = null,
+ @Json(name = "m.asset") val locationAsset: LocationAsset? = null
) : MessageContent {
- fun getBestGeoUri() = locationInfo?.geoUri ?: geoUri
+ fun getBestLocationInfo() = locationInfo ?: unstableLocationInfo
+
+ fun getBestTs() = ts ?: unstableTs
+
+ fun getBestText() = text ?: unstableText
+
+ fun getBestLocationAsset() = locationAsset ?: unstableLocationAsset
+
+ fun getBestGeoUri() = getBestLocationInfo()?.geoUri ?: geoUri
/**
* @return true if the location asset is a user location, not a generic one.
*/
fun isSelfLocation(): Boolean {
// Should behave like m.self if locationAsset is null
+ val locationAsset = getBestLocationAsset()
return locationAsset?.type == null || locationAsset.type == LocationAssetType.SELF
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt
index a4e1317290..43c0c90068 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt
@@ -31,5 +31,9 @@ data class MessagePollContent(
@Json(name = "body") override val body: String = "",
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
- @Json(name = "org.matrix.msc3381.poll.start") val pollCreationInfo: PollCreationInfo? = null
-) : MessageContent
+ @Json(name = "org.matrix.msc3381.poll.start") val unstablePollCreationInfo: PollCreationInfo? = null,
+ @Json(name = "m.poll.start") val pollCreationInfo: PollCreationInfo? = null
+) : MessageContent {
+
+ fun getBestPollCreationInfo() = pollCreationInfo ?: unstablePollCreationInfo
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt
index f3b4e3dc23..022915ed69 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt
@@ -31,5 +31,9 @@ data class MessagePollResponseContent(
@Json(name = "body") override val body: String = "",
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
- @Json(name = "org.matrix.msc3381.poll.response") val response: PollResponse? = null
-) : MessageContent
+ @Json(name = "org.matrix.msc3381.poll.response") val unstableResponse: PollResponse? = null,
+ @Json(name = "m.response") val response: PollResponse? = null
+) : MessageContent {
+
+ fun getBestResponse() = response ?: unstableResponse
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt
index 8f5ff53c85..34614d9d15 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollAnswer.kt
@@ -22,5 +22,9 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollAnswer(
@Json(name = "id") val id: String? = null,
- @Json(name = "org.matrix.msc1767.text") val answer: String? = null
-)
+ @Json(name = "org.matrix.msc1767.text") val unstableAnswer: String? = null,
+ @Json(name = "m.text") val answer: String? = null
+) {
+
+ fun getBestAnswer() = answer ?: unstableAnswer
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt
index a82c01b159..81b034a809 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt
@@ -21,8 +21,8 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollCreationInfo(
- @Json(name = "question") val question: PollQuestion? = null,
- @Json(name = "kind") val kind: PollType? = PollType.DISCLOSED,
- @Json(name = "max_selections") val maxSelections: Int = 1,
- @Json(name = "answers") val answers: List? = null
+ @Json(name = "question") val question: PollQuestion? = null,
+ @Json(name = "kind") val kind: PollType? = PollType.DISCLOSED_UNSTABLE,
+ @Json(name = "max_selections") val maxSelections: Int = 1,
+ @Json(name = "answers") val answers: List? = null
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt
index 76025f745e..df9517892b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollQuestion.kt
@@ -21,5 +21,9 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollQuestion(
- @Json(name = "org.matrix.msc1767.text") val question: String? = null
-)
+ @Json(name = "org.matrix.msc1767.text") val unstableQuestion: String? = null,
+ @Json(name = "m.text") val question: String? = null
+) {
+
+ fun getBestQuestion() = question ?: unstableQuestion
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt
index 3a8066b9bc..54801e698d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollType.kt
@@ -25,11 +25,17 @@ enum class PollType {
* Voters should see results as soon as they have voted.
*/
@Json(name = "org.matrix.msc3381.poll.disclosed")
+ DISCLOSED_UNSTABLE,
+
+ @Json(name = "m.poll.disclosed")
DISCLOSED,
/**
* Results should be only revealed when the poll is ended.
*/
@Json(name = "org.matrix.msc3381.poll.undisclosed")
+ UNDISCLOSED_UNSTABLE,
+
+ @Json(name = "m.poll.undisclosed")
UNDISCLOSED
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt
index 733d6c37e8..e7bebeeff6 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt
@@ -26,5 +26,6 @@ data class ReactionInfo(
@Json(name = "key") val key: String,
// always null for reaction
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null,
- @Json(name = "option") override val option: Int? = null
+ @Json(name = "option") override val option: Int? = null,
+ @Json(name = "is_falling_back") override val isFallingBack: Boolean? = null
) : RelationContent
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt
index e2080bb437..53b1fea873 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt
@@ -24,4 +24,10 @@ interface RelationContent {
val eventId: String?
val inReplyTo: ReplyToContent?
val option: Int?
+
+ /**
+ * This flag indicates that the message should be rendered as a reply
+ * fallback, when isFallingBack = false
+ */
+ val isFallingBack: Boolean?
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt
index 10b071a601..5dcb1b4323 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt
@@ -23,5 +23,8 @@ data class RelationDefaultContent(
@Json(name = "rel_type") override val type: String?,
@Json(name = "event_id") override val eventId: String?,
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null,
- @Json(name = "option") override val option: Int? = null
+ @Json(name = "option") override val option: Int? = null,
+ @Json(name = "is_falling_back") override val isFallingBack: Boolean? = null
) : RelationContent
+
+fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt
index 09114436f0..4409898908 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt
@@ -163,13 +163,4 @@ interface RelationService {
autoMarkdown: Boolean = false,
formattedText: String? = null,
eventReplied: TimelineEvent? = null): Cancelable?
-
- /**
- * Get all the thread replies for the specified rootThreadEventId
- * The return list will contain the original root thread event and all the thread replies to that event
- * Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready
- * from the backend
- * @param rootThreadEventId the root thread eventId
- */
- suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt
index 412a1bfca9..251328bea2 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt
@@ -21,8 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ReplyToContent(
- @Json(name = "event_id") val eventId: String? = null,
- @Json(name = "render_in") val renderIn: List? = null
+ @Json(name = "event_id") val eventId: String? = null
)
-
-fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
index 913dbfd010..9f8b1d93d7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
@@ -142,8 +142,9 @@ interface SendService {
* @param latitude required latitude of the location
* @param longitude required longitude of the location
* @param uncertainty Accuracy of the location in meters
+ * @param isUserLocation indicates whether the location data corresponds to the user location or not
*/
- fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable
+ fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable
/**
* Remove this failed message from the timeline
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
index f81a1a62fa..88d05c1930 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
@@ -33,8 +33,7 @@ object RoomSummaryConstants {
EventType.ENCRYPTED,
EventType.STICKER,
EventType.REACTION,
- EventType.POLL_START
- )
+ ) + EventType.POLL_START
// SC addition | this is the Element behaviour previous to Element v1.0.7
val PREVIEWABLE_TYPES_ALL = listOf(
@@ -54,7 +53,7 @@ object RoomSummaryConstants {
EventType.STICKER,
EventType.REACTION,
EventType.STATE_ROOM_CREATE
- )
+ ) + EventType.POLL_START
// SC addition | no reactions in here
val PREVIEWABLE_ORIGINAL_CONTENT_TYPES = listOf(
@@ -65,5 +64,5 @@ object RoomSummaryConstants {
EventType.CALL_ANSWER,
EventType.ENCRYPTED,
EventType.STICKER
- )
+ ) + EventType.POLL_START
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt
index e4d1d979e1..839cdff63b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt
@@ -17,51 +17,43 @@
package org.matrix.android.sdk.api.session.room.threads
import androidx.lifecycle.LiveData
-import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
/**
- * This interface defines methods to interact with threads related features.
- * It's implemented at the room level within the main timeline.
+ * This interface defines methods to interact with thread related features.
+ * It's the dynamic threads implementation and the homeserver must return
+ * a capability entry for threads. If the server do not support m.thread
+ * then [ThreadsLocalService] should be used instead
*/
interface ThreadsService {
/**
- * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level
+ * Returns a [LiveData] list of all the [ThreadSummary] that exists at the room level
*/
- fun getAllThreadsLive(): LiveData>
+ fun getAllThreadSummariesLive(): LiveData>
/**
- * Returns a list of all the thread root TimelineEvents that exists at the room level
+ * Returns a list of all the [ThreadSummary] that exists at the room level
*/
- fun getAllThreads(): List
+ fun getAllThreadSummaries(): List
/**
- * Returns a [LiveData] list of all the marked unread threads that exists at the room level
- */
- fun getMarkedThreadNotificationsLive(): LiveData>
-
- /**
- * Returns a list of all the marked unread threads that exists at the room level
- */
- fun getMarkedThreadNotifications(): List
-
- /**
- * Returns whether or not the current user is participating in the thread
- * @param rootThreadEventId the eventId of the current thread
- */
- fun isUserParticipatingInThread(rootThreadEventId: String): Boolean
-
- /**
- * Enhance the provided root thread TimelineEvent [List] by adding the latest
+ * Enhance the provided ThreadSummary[List] by adding the latest
* message edition for that thread
* @return the enhanced [List] with edited updates
*/
- fun mapEventsWithEdition(threads: List): List
+ fun enhanceThreadWithEditions(threads: List): List
/**
- * Marks the current thread as read in local DB.
- * note: read receipts within threads are not yet supported with the API
- * @param rootThreadEventId the root eventId of the current thread
+ * Fetch all thread replies for the specified thread using the /relations api
+ * @param rootThreadEventId the root thread eventId
+ * @param from defines the token that will fetch from that position
+ * @param limit defines the number of max results the api will respond with
*/
- suspend fun markThreadAsRead(rootThreadEventId: String)
+ suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int)
+
+ /**
+ * Fetch all thread summaries for the current room using the enhanced /messages api
+ */
+ suspend fun fetchThreadSummaries()
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt
new file mode 100644
index 0000000000..f7b379e382
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.room.threads.local
+
+import androidx.lifecycle.LiveData
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+
+/**
+ * This interface defines methods to interact with thread related features.
+ * It's the local threads implementation and assumes that the homeserver
+ * do not support threads
+ */
+interface ThreadsLocalService {
+
+ /**
+ * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level
+ */
+ fun getAllThreadsLive(): LiveData>
+
+ /**
+ * Returns a list of all the thread root TimelineEvents that exists at the room level
+ */
+ fun getAllThreads(): List
+
+ /**
+ * Returns a [LiveData] list of all the marked unread threads that exists at the room level
+ */
+ fun getMarkedThreadNotificationsLive(): LiveData>
+
+ /**
+ * Returns a list of all the marked unread threads that exists at the room level
+ */
+ fun getMarkedThreadNotifications(): List
+
+ /**
+ * Returns whether or not the current user is participating in the thread
+ * @param rootThreadEventId the eventId of the current thread
+ */
+ fun isUserParticipatingInThread(rootThreadEventId: String): Boolean
+
+ /**
+ * Enhance the provided root thread TimelineEvent [List] by adding the latest
+ * message edition for that thread
+ * @return the enhanced [List] with edited updates
+ */
+ fun mapEventsWithEdition(threads: List): List
+
+ /**
+ * Marks the current thread as read in local DB.
+ * note: read receipts within threads are not yet supported with the API
+ * @param rootThreadEventId the root eventId of the current thread
+ */
+ suspend fun markThreadAsRead(rootThreadEventId: String)
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt
new file mode 100644
index 0000000000..c8353cf0de
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.room.threads.model
+
+data class ThreadEditions(var rootThreadEdition: String? = null,
+ var latestThreadEdition: String? = null)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt
new file mode 100644
index 0000000000..017afba1ba
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.room.threads.model
+
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.room.sender.SenderInfo
+
+/**
+ * The main thread Summary model, mainly used to display the thread list
+ */
+data class ThreadSummary(val roomId: String,
+ val rootEvent: Event?,
+ val latestEvent: Event?,
+ val rootEventId: String,
+ val rootThreadSenderInfo: SenderInfo,
+ val latestThreadSenderInfo: SenderInfo,
+ val isUserParticipating: Boolean,
+ val numberOfThreads: Int,
+ val threadEditions: ThreadEditions = ThreadEditions())
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt
new file mode 100644
index 0000000000..95697f987f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.room.threads.model
+
+enum class ThreadSummaryUpdateType {
+ REPLACE,
+ ADD
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
index 6f8bae876b..1b01efc074 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
@@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.session.room.timeline
import org.matrix.android.sdk.BuildConfig
+import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
@@ -54,6 +55,7 @@ data class TimelineEvent(
* It's not unique on the timeline as it's reset on each chunk.
*/
val displayIndex: Int,
+ var ownedByThreadChunk: Boolean = false,
val senderInfo: SenderInfo,
val annotations: EventAnnotationsSummary? = null,
val readReceipts: List = emptyList()
@@ -134,9 +136,9 @@ fun TimelineEvent.getEditedEventId(): String? {
*/
fun TimelineEvent.getLastMessageContent(): MessageContent? {
return when (root.getClearType()) {
- EventType.STICKER -> root.getClearContent().toModel()
- EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
- else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
+ EventType.STICKER -> root.getClearContent().toModel()
+ in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
+ else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
}
}
@@ -158,6 +160,13 @@ fun TimelineEvent.isSticker(): Boolean {
return root.isSticker()
}
+/**
+ * Returns whether or not the event is a root thread event
+ */
+fun TimelineEvent.isRootThread(): Boolean {
+ return root.threadDetails?.isRootThread.orFalse()
+}
+
/**
* Get the latest message body, after a possible edition, stripping the reply prefix if necessary
*/
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
index 6152069644..46433f387d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
@@ -38,7 +38,7 @@ interface TimelineService {
/**
* Returns a snapshot of TimelineEvent event with eventId.
- * At the opposite of getTimeLineEventLive which will be updated when local echo event is synced, it will return null in this case.
+ * At the opposite of getTimelineEventLive which will be updated when local echo event is synced, it will return null in this case.
* @param eventId the eventId to get the TimelineEvent
*/
fun getTimelineEvent(eventId: String): TimelineEvent?
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt
index fafe17b2c0..d6937d5b26 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt
@@ -16,6 +16,7 @@
package org.matrix.android.sdk.api.session.threads
+import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
/**
@@ -26,7 +27,7 @@ data class ThreadDetails(
val isRootThread: Boolean = false,
val numberOfThreads: Int = 0,
val threadSummarySenderInfo: SenderInfo? = null,
- val threadSummaryLatestTextMessage: String? = null,
+ val threadSummaryLatestEvent: Event? = null,
val lastMessageTimestamp: Long? = null,
var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE,
val isThread: Boolean = false,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt
index 302f7387fa..650b8cc26d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt
@@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.util
import org.matrix.android.sdk.BuildConfig
+import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.group.model.GroupSummary
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@@ -199,6 +200,8 @@ fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName,
fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl)
+fun SenderInfo.toMatrixItemOrNull() = tryOrNull { MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) }
+
fun SpaceChildInfo.toMatrixItem() = if (roomType == RoomType.SPACE) {
MatrixItem.SpaceItem(childRoomId, name ?: canonicalAlias, avatarUrl)
} else {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt
index 0a9b8b73cc..815f8de2de 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt
@@ -38,7 +38,7 @@ internal data class HomeServerVersion(
}
companion object {
- internal val pattern = Regex("""r(\d+)\.(\d+)\.(\d+)""")
+ internal val pattern = Regex("""[r|v](\d+)\.(\d+)\.(\d+)""")
internal fun parse(value: String): HomeServerVersion? {
val result = pattern.matchEntire(value) ?: return null
@@ -56,5 +56,6 @@ internal data class HomeServerVersion(
val r0_4_0 = HomeServerVersion(major = 0, minor = 4, patch = 0)
val r0_5_0 = HomeServerVersion(major = 0, minor = 5, patch = 0)
val r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0)
+ val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0)
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt
index 74cb3de2ac..d07d5ecd64 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt
@@ -51,6 +51,8 @@ private const val FEATURE_LAZY_LOAD_MEMBERS = "m.lazy_load_members"
private const val FEATURE_REQUIRE_IDENTITY_SERVER = "m.require_identity_server"
private const val FEATURE_ID_ACCESS_TOKEN = "m.id_access_token"
private const val FEATURE_SEPARATE_ADD_AND_BIND = "m.separate_add_and_bind"
+private const val FEATURE_THREADS_MSC3440 = "org.matrix.msc3440"
+private const val FEATURE_THREADS_MSC3440_STABLE = "org.matrix.msc3440.stable"
/**
* Return true if the SDK supports this homeserver version
@@ -68,6 +70,14 @@ internal fun Versions.isLoginAndRegistrationSupportedBySdk(): Boolean {
doesServerSeparatesAddAndBind()
}
+/**
+ * Indicate if the homeserver support MSC3440 for threads
+ */
+internal fun Versions.doesServerSupportThreads(): Boolean {
+ return getMaxVersion() >= HomeServerVersion.v1_3_0 ||
+ unstableFeatures?.get(FEATURE_THREADS_MSC3440_STABLE) ?: false
+}
+
/**
* Return true if the server support the lazy loading of room members
*
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
index 0646e4d2b8..db44abc36f 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
@@ -434,6 +434,14 @@ internal class DefaultCryptoService @Inject constructor(
val currentCount = syncResponse.deviceOneTimeKeysCount.signedCurve25519 ?: 0
oneTimeKeysUploader.updateOneTimeKeyCount(currentCount)
}
+
+ // unwedge if needed
+ try {
+ eventDecryptor.unwedgeDevicesIfNeeded()
+ } catch (failure: Throwable) {
+ Timber.tag(loggerTag.value).w("unwedgeDevicesIfNeeded failed")
+ }
+
// There is a limit of to_device events returned per sync.
// If we are in a case of such limited to_device sync we can't try to generate/upload
// new otk now, because there might be some pending olm pre-key to_device messages that would fail if we rotate
@@ -723,7 +731,7 @@ internal class DefaultCryptoService @Inject constructor(
* @return the MXEventDecryptionResult data, or throw in case of error
*/
@Throws(MXCryptoError::class)
- override fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
+ override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
return internalDecryptEvent(event, timeline)
}
@@ -746,7 +754,7 @@ internal class DefaultCryptoService @Inject constructor(
* @return the MXEventDecryptionResult data, or null in case of error
*/
@Throws(MXCryptoError::class)
- private fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
+ private suspend fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
return eventDecryptor.decryptEvent(event, timeline)
}
@@ -1364,6 +1372,9 @@ internal class DefaultCryptoService @Inject constructor(
@VisibleForTesting
val cryptoStoreForTesting = cryptoStore
+ @VisibleForTesting
+ val olmDeviceForTest = olmDevice
+
companion object {
const val CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS = 3_600_000 // one hour
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt
index 57381eacfb..00efd3d6a8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt
@@ -21,14 +21,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
+import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.internal.crypto.actions.EnsureOlmSessionsForDevicesAction
import org.matrix.android.sdk.internal.crypto.actions.MessageEncrypter
-import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
-import org.matrix.android.sdk.internal.crypto.model.MXOlmSessionResult
import org.matrix.android.sdk.internal.crypto.model.MXUsersDevicesMap
import org.matrix.android.sdk.internal.crypto.model.event.OlmEventContent
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
@@ -40,6 +39,8 @@ import javax.inject.Inject
private const val SEND_TO_DEVICE_RETRY_COUNT = 3
+private val loggerTag = LoggerTag("CryptoSyncHandler", LoggerTag.CRYPTO)
+
@SessionScope
internal class EventDecryptor @Inject constructor(
private val cryptoCoroutineScope: CoroutineScope,
@@ -47,13 +48,22 @@ internal class EventDecryptor @Inject constructor(
private val roomDecryptorProvider: RoomDecryptorProvider,
private val messageEncrypter: MessageEncrypter,
private val sendToDeviceTask: SendToDeviceTask,
+ private val deviceListManager: DeviceListManager,
private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction,
private val cryptoStore: IMXCryptoStore
) {
- // The date of the last time we forced establishment
- // of a new session for each user:device.
- private val lastNewSessionForcedDates = MXUsersDevicesMap()
+ /**
+ * Rate limit unwedge attempt, should we persist that?
+ */
+ private val lastNewSessionForcedDates = mutableMapOf()
+
+ data class WedgedDeviceInfo(
+ val userId: String,
+ val senderKey: String?
+ )
+
+ private val wedgedDevices = mutableListOf()
/**
* Decrypt an event
@@ -63,7 +73,7 @@ internal class EventDecryptor @Inject constructor(
* @return the MXEventDecryptionResult data, or throw in case of error
*/
@Throws(MXCryptoError::class)
- fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
+ suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
return internalDecryptEvent(event, timeline)
}
@@ -91,38 +101,32 @@ internal class EventDecryptor @Inject constructor(
* @return the MXEventDecryptionResult data, or null in case of error
*/
@Throws(MXCryptoError::class)
- private fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
+ private suspend fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
val eventContent = event.content
if (eventContent == null) {
- Timber.e("## CRYPTO | decryptEvent : empty event content")
+ Timber.tag(loggerTag.value).e("decryptEvent : empty event content")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE, MXCryptoError.BAD_ENCRYPTED_MESSAGE_REASON)
} else {
val algorithm = eventContent["algorithm"]?.toString()
val alg = roomDecryptorProvider.getOrCreateRoomDecryptor(event.roomId, algorithm)
if (alg == null) {
val reason = String.format(MXCryptoError.UNABLE_TO_DECRYPT_REASON, event.eventId, algorithm)
- Timber.e("## CRYPTO | decryptEvent() : $reason")
+ Timber.tag(loggerTag.value).e("decryptEvent() : $reason")
throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, reason)
} else {
try {
return alg.decryptEvent(event, timeline)
} catch (mxCryptoError: MXCryptoError) {
- Timber.v("## CRYPTO | internalDecryptEvent : Failed to decrypt ${event.eventId} reason: $mxCryptoError")
+ Timber.tag(loggerTag.value).d("internalDecryptEvent : Failed to decrypt ${event.eventId} reason: $mxCryptoError")
if (algorithm == MXCRYPTO_ALGORITHM_OLM) {
if (mxCryptoError is MXCryptoError.Base &&
mxCryptoError.errorType == MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE) {
// need to find sending device
- cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
- val olmContent = event.content.toModel()
- cryptoStore.getUserDevices(event.senderId ?: "")
- ?.values
- ?.firstOrNull { it.identityKey() == olmContent?.senderKey }
- ?.let {
- markOlmSessionForUnwedging(event.senderId ?: "", it)
- }
- ?: run {
- Timber.i("## CRYPTO | internalDecryptEvent() : Failed to find sender crypto device for unwedging")
- }
+ val olmContent = event.content.toModel()
+ if (event.senderId != null && olmContent?.senderKey != null) {
+ markOlmSessionForUnwedging(event.senderId, olmContent.senderKey)
+ } else {
+ Timber.tag(loggerTag.value).d("Can't mark as wedge malformed")
}
}
}
@@ -132,53 +136,91 @@ internal class EventDecryptor @Inject constructor(
}
}
- // coroutineDispatchers.crypto scope
- private fun markOlmSessionForUnwedging(senderId: String, deviceInfo: CryptoDeviceInfo) {
- val deviceKey = deviceInfo.identityKey()
+ private fun markOlmSessionForUnwedging(senderId: String, senderKey: String) {
+ val info = WedgedDeviceInfo(senderId, senderKey)
+ if (!wedgedDevices.contains(info)) {
+ Timber.tag(loggerTag.value).d("Marking device from $senderId key:$senderKey as wedged")
+ wedgedDevices.add(info)
+ }
+ }
- val lastForcedDate = lastNewSessionForcedDates.getObject(senderId, deviceKey) ?: 0
+ // coroutineDispatchers.crypto scope
+ suspend fun unwedgeDevicesIfNeeded() {
+ // handle wedged devices
+ // Some olm decryption have failed and some device are wedged
+ // we should force start a new session for those
+ Timber.tag(loggerTag.value).v("Unwedging: ${wedgedDevices.size} are wedged")
+ // get the one that should be retried according to rate limit
val now = System.currentTimeMillis()
- if (now - lastForcedDate < DefaultCryptoService.CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS) {
- Timber.w("## CRYPTO | markOlmSessionForUnwedging: New session already forced with device at $lastForcedDate. Not forcing another")
+ val toUnwedge = wedgedDevices.filter {
+ val lastForcedDate = lastNewSessionForcedDates[it] ?: 0
+ if (now - lastForcedDate < DefaultCryptoService.CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS) {
+ Timber.tag(loggerTag.value).d("Unwedging, New session for $it already forced with device at $lastForcedDate")
+ return@filter false
+ }
+ // let's already mark that we tried now
+ lastNewSessionForcedDates[it] = now
+ true
+ }
+
+ if (toUnwedge.isEmpty()) {
+ Timber.tag(loggerTag.value).v("Nothing to unwedge")
return
}
+ Timber.tag(loggerTag.value).d("Unwedging, trying to create new session for ${toUnwedge.size} devices")
- Timber.i("## CRYPTO | markOlmSessionForUnwedging from $senderId:${deviceInfo.deviceId}")
- lastNewSessionForcedDates.setObject(senderId, deviceKey, now)
-
- // offload this from crypto thread (?)
- cryptoCoroutineScope.launch(coroutineDispatchers.computation) {
- runCatching { ensureOlmSessionsForDevicesAction.handle(mapOf(senderId to listOf(deviceInfo)), force = true) }.fold(
- onSuccess = { sendDummyToDevice(ensured = it, deviceInfo, senderId) },
- onFailure = {
- Timber.e("## CRYPTO | markOlmSessionForUnwedging() : failed to ensure device info ${senderId}${deviceInfo.deviceId}")
+ toUnwedge
+ .chunked(100) // safer to chunk if we ever have lots of wedged devices
+ .forEach { wedgedList ->
+ val groupedByUserId = wedgedList.groupBy { it.userId }
+ // lets download keys if needed
+ withContext(coroutineDispatchers.io) {
+ deviceListManager.downloadKeys(groupedByUserId.keys.toList(), false)
}
- )
- }
- }
- private suspend fun sendDummyToDevice(ensured: MXUsersDevicesMap, deviceInfo: CryptoDeviceInfo, senderId: String) {
- Timber.i("## CRYPTO | markOlmSessionForUnwedging() : ensureOlmSessionsForDevicesAction isEmpty:${ensured.isEmpty}")
+ // find the matching devices
+ groupedByUserId
+ .map { groupedByUser ->
+ val userId = groupedByUser.key
+ val wedgeSenderKeysForUser = groupedByUser.value.map { it.senderKey }
+ val knownDevices = cryptoStore.getUserDevices(userId)?.values.orEmpty()
+ userId to wedgeSenderKeysForUser.mapNotNull { senderKey ->
+ knownDevices.firstOrNull { it.identityKey() == senderKey }
+ }
+ }
+ .toMap()
+ .let { deviceList ->
+ try {
+ // force creating new outbound session and mark them as most recent to
+ // be used for next encryption (dummy)
+ val sessionToUse = ensureOlmSessionsForDevicesAction.handle(deviceList, true)
+ Timber.tag(loggerTag.value).d("Unwedging, found ${sessionToUse.map.size} to send dummy to")
- // Now send a blank message on that session so the other side knows about it.
- // (The keyshare request is sent in the clear so that won't do)
- // We send this first such that, as long as the toDevice messages arrive in the
- // same order we sent them, the other end will get this first, set up the new session,
- // then get the keyshare request and send the key over this new session (because it
- // is the session it has most recently received a message on).
- val payloadJson = mapOf("type" to EventType.DUMMY)
+ // Now send a dummy message on that session so the other side knows about it.
+ val payloadJson = mapOf(
+ "type" to EventType.DUMMY
+ )
+ val sendToDeviceMap = MXUsersDevicesMap()
+ sessionToUse.map.values
+ .flatMap { it.values }
+ .map { it.deviceInfo }
+ .forEach { deviceInfo ->
+ Timber.tag(loggerTag.value).v("encrypting dummy to ${deviceInfo.deviceId}")
+ val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo))
+ sendToDeviceMap.setObject(deviceInfo.userId, deviceInfo.deviceId, encodedPayload)
+ }
- val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo))
- val sendToDeviceMap = MXUsersDevicesMap()
- sendToDeviceMap.setObject(senderId, deviceInfo.deviceId, encodedPayload)
- Timber.i("## CRYPTO | markOlmSessionForUnwedging() : sending dummy to $senderId:${deviceInfo.deviceId}")
- withContext(coroutineDispatchers.io) {
- val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap)
- try {
- sendToDeviceTask.executeRetry(sendToDeviceParams, remainingRetry = SEND_TO_DEVICE_RETRY_COUNT)
- } catch (failure: Throwable) {
- Timber.e(failure, "## CRYPTO | markOlmSessionForUnwedging() : failed to send dummy to $senderId:${deviceInfo.deviceId}")
- }
- }
+ // now let's send that
+ val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap)
+ withContext(coroutineDispatchers.io) {
+ sendToDeviceTask.executeRetry(sendToDeviceParams, remainingRetry = SEND_TO_DEVICE_RETRY_COUNT)
+ }
+ } catch (failure: Throwable) {
+ deviceList.flatMap { it.value }.joinToString { it.shortDebugString() }.let {
+ Timber.tag(loggerTag.value).e(failure, "## Failed to unwedge devices: $it}")
+ }
+ }
+ }
+ }
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt
index e7a46750b0..34bef61c98 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt
@@ -19,8 +19,10 @@ package org.matrix.android.sdk.internal.crypto
import android.util.LruCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.extensions.tryOrNull
+import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
import timber.log.Timber
@@ -28,6 +30,13 @@ import java.util.Timer
import java.util.TimerTask
import javax.inject.Inject
+data class InboundGroupSessionHolder(
+ val wrapper: OlmInboundGroupSessionWrapper2,
+ val mutex: Mutex = Mutex()
+)
+
+private val loggerTag = LoggerTag("InboundGroupSessionStore", LoggerTag.CRYPTO)
+
/**
* Allows to cache and batch store operations on inbound group session store.
* Because it is used in the decrypt flow, that can be called quite rapidly
@@ -42,12 +51,13 @@ internal class InboundGroupSessionStore @Inject constructor(
val senderKey: String
)
- private val sessionCache = object : LruCache(30) {
- override fun entryRemoved(evicted: Boolean, key: CacheKey?, oldValue: OlmInboundGroupSessionWrapper2?, newValue: OlmInboundGroupSessionWrapper2?) {
- if (evicted && oldValue != null) {
+ private val sessionCache = object : LruCache(100) {
+ override fun entryRemoved(evicted: Boolean, key: CacheKey?, oldValue: InboundGroupSessionHolder?, newValue: InboundGroupSessionHolder?) {
+ if (oldValue != null) {
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
- Timber.v("## Inbound: entryRemoved ${oldValue.roomId}-${oldValue.senderKey}")
- store.storeInboundGroupSessions(listOf(oldValue))
+ Timber.tag(loggerTag.value).v("## Inbound: entryRemoved ${oldValue.wrapper.roomId}-${oldValue.wrapper.senderKey}")
+ store.storeInboundGroupSessions(listOf(oldValue).map { it.wrapper })
+ oldValue.wrapper.olmInboundGroupSession?.releaseSession()
}
}
}
@@ -59,27 +69,50 @@ internal class InboundGroupSessionStore @Inject constructor(
private val dirtySession = mutableListOf()
@Synchronized
- fun getInboundGroupSession(sessionId: String, senderKey: String): OlmInboundGroupSessionWrapper2? {
- synchronized(sessionCache) {
- val known = sessionCache[CacheKey(sessionId, senderKey)]
- Timber.v("## Inbound: getInboundGroupSession in cache ${known != null}")
- return known ?: store.getInboundGroupSession(sessionId, senderKey)?.also {
- Timber.v("## Inbound: getInboundGroupSession cache populate ${it.roomId}")
- sessionCache.put(CacheKey(sessionId, senderKey), it)
- }
- }
+ fun clear() {
+ sessionCache.evictAll()
}
@Synchronized
- fun storeInBoundGroupSession(wrapper: OlmInboundGroupSessionWrapper2, sessionId: String, senderKey: String) {
- Timber.v("## Inbound: getInboundGroupSession mark as dirty ${wrapper.roomId}-${wrapper.senderKey}")
+ fun getInboundGroupSession(sessionId: String, senderKey: String): InboundGroupSessionHolder? {
+ val known = sessionCache[CacheKey(sessionId, senderKey)]
+ Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession $sessionId in cache ${known != null}")
+ return known
+ ?: store.getInboundGroupSession(sessionId, senderKey)?.also {
+ Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession cache populate ${it.roomId}")
+ sessionCache.put(CacheKey(sessionId, senderKey), InboundGroupSessionHolder(it))
+ }?.let {
+ InboundGroupSessionHolder(it)
+ }
+ }
+
+ @Synchronized
+ fun replaceGroupSession(old: InboundGroupSessionHolder, new: InboundGroupSessionHolder, sessionId: String, senderKey: String) {
+ Timber.tag(loggerTag.value).v("## Replacing outdated session ${old.wrapper.roomId}-${old.wrapper.senderKey}")
+ dirtySession.remove(old.wrapper)
+ store.removeInboundGroupSession(sessionId, senderKey)
+ sessionCache.remove(CacheKey(sessionId, senderKey))
+
+ // release removed session
+ old.wrapper.olmInboundGroupSession?.releaseSession()
+
+ internalStoreGroupSession(new, sessionId, senderKey)
+ }
+
+ @Synchronized
+ fun storeInBoundGroupSession(holder: InboundGroupSessionHolder, sessionId: String, senderKey: String) {
+ internalStoreGroupSession(holder, sessionId, senderKey)
+ }
+
+ private fun internalStoreGroupSession(holder: InboundGroupSessionHolder, sessionId: String, senderKey: String) {
+ Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession mark as dirty ${holder.wrapper.roomId}-${holder.wrapper.senderKey}")
// We want to batch this a bit for performances
- dirtySession.add(wrapper)
+ dirtySession.add(holder.wrapper)
if (sessionCache[CacheKey(sessionId, senderKey)] == null) {
// first time seen, put it in memory cache while waiting for batch insert
// If it's already known, no need to update cache it's already there
- sessionCache.put(CacheKey(sessionId, senderKey), wrapper)
+ sessionCache.put(CacheKey(sessionId, senderKey), holder)
}
timerTask?.cancel()
@@ -96,7 +129,7 @@ internal class InboundGroupSessionStore @Inject constructor(
val toSave = mutableListOf().apply { addAll(dirtySession) }
dirtySession.clear()
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
- Timber.v("## Inbound: getInboundGroupSession batching save of ${dirtySession.size}")
+ Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession batching save of ${toSave.size}")
tryOrNull {
store.storeInboundGroupSessions(toSave)
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt
index e1a706df79..501fb42db2 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt
@@ -16,6 +16,11 @@
package org.matrix.android.sdk.internal.crypto
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.matrix.android.sdk.api.extensions.tryOrNull
+import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.util.JSON_DICT_PARAMETERIZED_TYPE
import org.matrix.android.sdk.api.util.JsonDict
@@ -40,6 +45,8 @@ import timber.log.Timber
import java.net.URLEncoder
import javax.inject.Inject
+private val loggerTag = LoggerTag("MXOlmDevice", LoggerTag.CRYPTO)
+
// The libolm wrapper.
@SessionScope
internal class MXOlmDevice @Inject constructor(
@@ -47,9 +54,12 @@ internal class MXOlmDevice @Inject constructor(
* The store where crypto data is saved.
*/
private val store: IMXCryptoStore,
+ private val olmSessionStore: OlmSessionStore,
private val inboundGroupSessionStore: InboundGroupSessionStore
) {
+ val mutex = Mutex()
+
/**
* @return the Curve25519 key for the account.
*/
@@ -93,26 +103,26 @@ internal class MXOlmDevice @Inject constructor(
try {
store.getOrCreateOlmAccount()
} catch (e: Exception) {
- Timber.e(e, "MXOlmDevice : cannot initialize olmAccount")
+ Timber.tag(loggerTag.value).e(e, "MXOlmDevice : cannot initialize olmAccount")
}
try {
olmUtility = OlmUtility()
} catch (e: Exception) {
- Timber.e(e, "## MXOlmDevice : OlmUtility failed with error")
+ Timber.tag(loggerTag.value).e(e, "## MXOlmDevice : OlmUtility failed with error")
olmUtility = null
}
try {
- deviceCurve25519Key = store.getOlmAccount().identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY]
+ deviceCurve25519Key = store.doWithOlmAccount { it.identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY] }
} catch (e: Exception) {
- Timber.e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_IDENTITY_KEY} with error")
+ Timber.tag(loggerTag.value).e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_IDENTITY_KEY} with error")
}
try {
- deviceEd25519Key = store.getOlmAccount().identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY]
+ deviceEd25519Key = store.doWithOlmAccount { it.identityKeys()[OlmAccount.JSON_KEY_FINGER_PRINT_KEY] }
} catch (e: Exception) {
- Timber.e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_FINGER_PRINT_KEY} with error")
+ Timber.tag(loggerTag.value).e(e, "## MXOlmDevice : cannot find ${OlmAccount.JSON_KEY_FINGER_PRINT_KEY} with error")
}
}
@@ -121,9 +131,9 @@ internal class MXOlmDevice @Inject constructor(
*/
fun getOneTimeKeys(): Map>? {
try {
- return store.getOlmAccount().oneTimeKeys()
+ return store.doWithOlmAccount { it.oneTimeKeys() }
} catch (e: Exception) {
- Timber.e(e, "## getOneTimeKeys() : failed")
+ Timber.tag(loggerTag.value).e(e, "## getOneTimeKeys() : failed")
}
return null
@@ -133,7 +143,7 @@ internal class MXOlmDevice @Inject constructor(
* @return The maximum number of one-time keys the olm account can store.
*/
fun getMaxNumberOfOneTimeKeys(): Long {
- return store.getOlmAccount().maxOneTimeKeys()
+ return store.doWithOlmAccount { it.maxOneTimeKeys() }
}
/**
@@ -143,9 +153,9 @@ internal class MXOlmDevice @Inject constructor(
*/
fun getFallbackKey(): MutableMap>? {
try {
- return store.getOlmAccount().fallbackKey()
+ return store.doWithOlmAccount { it.fallbackKey() }
} catch (e: Exception) {
- Timber.e("## getFallbackKey() : failed")
+ Timber.tag(loggerTag.value).e("## getFallbackKey() : failed")
}
return null
}
@@ -158,12 +168,14 @@ internal class MXOlmDevice @Inject constructor(
fun generateFallbackKeyIfNeeded(): Boolean {
try {
if (!hasUnpublishedFallbackKey()) {
- store.getOlmAccount().generateFallbackKey()
- store.saveOlmAccount()
+ store.doWithOlmAccount {
+ it.generateFallbackKey()
+ store.saveOlmAccount()
+ }
return true
}
} catch (e: Exception) {
- Timber.e("## generateFallbackKey() : failed")
+ Timber.tag(loggerTag.value).e("## generateFallbackKey() : failed")
}
return false
}
@@ -174,10 +186,12 @@ internal class MXOlmDevice @Inject constructor(
fun forgetFallbackKey() {
try {
- store.getOlmAccount().forgetFallbackKey()
- store.saveOlmAccount()
+ store.doWithOlmAccount {
+ it.forgetFallbackKey()
+ store.saveOlmAccount()
+ }
} catch (e: Exception) {
- Timber.e("## forgetFallbackKey() : failed")
+ Timber.tag(loggerTag.value).e("## forgetFallbackKey() : failed")
}
}
@@ -190,6 +204,8 @@ internal class MXOlmDevice @Inject constructor(
it.groupSession.releaseSession()
}
outboundGroupSessionCache.clear()
+ inboundGroupSessionStore.clear()
+ olmSessionStore.clear()
}
/**
@@ -200,9 +216,9 @@ internal class MXOlmDevice @Inject constructor(
*/
fun signMessage(message: String): String? {
try {
- return store.getOlmAccount().signMessage(message)
+ return store.doWithOlmAccount { it.signMessage(message) }
} catch (e: Exception) {
- Timber.e(e, "## signMessage() : failed")
+ Timber.tag(loggerTag.value).e(e, "## signMessage() : failed")
}
return null
@@ -213,10 +229,12 @@ internal class MXOlmDevice @Inject constructor(
*/
fun markKeysAsPublished() {
try {
- store.getOlmAccount().markOneTimeKeysAsPublished()
- store.saveOlmAccount()
+ store.doWithOlmAccount {
+ it.markOneTimeKeysAsPublished()
+ store.saveOlmAccount()
+ }
} catch (e: Exception) {
- Timber.e(e, "## markKeysAsPublished() : failed")
+ Timber.tag(loggerTag.value).e(e, "## markKeysAsPublished() : failed")
}
}
@@ -227,10 +245,12 @@ internal class MXOlmDevice @Inject constructor(
*/
fun generateOneTimeKeys(numKeys: Int) {
try {
- store.getOlmAccount().generateOneTimeKeys(numKeys)
- store.saveOlmAccount()
+ store.doWithOlmAccount {
+ it.generateOneTimeKeys(numKeys)
+ store.saveOlmAccount()
+ }
} catch (e: Exception) {
- Timber.e(e, "## generateOneTimeKeys() : failed")
+ Timber.tag(loggerTag.value).e(e, "## generateOneTimeKeys() : failed")
}
}
@@ -243,12 +263,14 @@ internal class MXOlmDevice @Inject constructor(
* @return the session id for the outbound session.
*/
fun createOutboundSession(theirIdentityKey: String, theirOneTimeKey: String): String? {
- Timber.v("## createOutboundSession() ; theirIdentityKey $theirIdentityKey theirOneTimeKey $theirOneTimeKey")
+ Timber.tag(loggerTag.value).d("## createOutboundSession() ; theirIdentityKey $theirIdentityKey theirOneTimeKey $theirOneTimeKey")
var olmSession: OlmSession? = null
try {
olmSession = OlmSession()
- olmSession.initOutboundSession(store.getOlmAccount(), theirIdentityKey, theirOneTimeKey)
+ store.doWithOlmAccount { olmAccount ->
+ olmSession.initOutboundSession(olmAccount, theirIdentityKey, theirOneTimeKey)
+ }
val olmSessionWrapper = OlmSessionWrapper(olmSession, 0)
@@ -257,14 +279,14 @@ internal class MXOlmDevice @Inject constructor(
// this session
olmSessionWrapper.onMessageReceived()
- store.storeSession(olmSessionWrapper, theirIdentityKey)
+ olmSessionStore.storeSession(olmSessionWrapper, theirIdentityKey)
val sessionIdentifier = olmSession.sessionIdentifier()
- Timber.v("## createOutboundSession() ; olmSession.sessionIdentifier: $sessionIdentifier")
+ Timber.tag(loggerTag.value).v("## createOutboundSession() ; olmSession.sessionIdentifier: $sessionIdentifier")
return sessionIdentifier
} catch (e: Exception) {
- Timber.e(e, "## createOutboundSession() failed")
+ Timber.tag(loggerTag.value).e(e, "## createOutboundSession() failed")
olmSession?.releaseSession()
}
@@ -281,34 +303,38 @@ internal class MXOlmDevice @Inject constructor(
* @return {{payload: string, session_id: string}} decrypted payload, and session id of new session.
*/
fun createInboundSession(theirDeviceIdentityKey: String, messageType: Int, ciphertext: String): Map? {
- Timber.v("## createInboundSession() : theirIdentityKey: $theirDeviceIdentityKey")
+ Timber.tag(loggerTag.value).d("## createInboundSession() : theirIdentityKey: $theirDeviceIdentityKey")
var olmSession: OlmSession? = null
try {
try {
olmSession = OlmSession()
- olmSession.initInboundSessionFrom(store.getOlmAccount(), theirDeviceIdentityKey, ciphertext)
+ store.doWithOlmAccount { olmAccount ->
+ olmSession.initInboundSessionFrom(olmAccount, theirDeviceIdentityKey, ciphertext)
+ }
} catch (e: Exception) {
- Timber.e(e, "## createInboundSession() : the session creation failed")
+ Timber.tag(loggerTag.value).e(e, "## createInboundSession() : the session creation failed")
return null
}
- Timber.v("## createInboundSession() : sessionId: ${olmSession.sessionIdentifier()}")
+ Timber.tag(loggerTag.value).v("## createInboundSession() : sessionId: ${olmSession.sessionIdentifier()}")
try {
- store.getOlmAccount().removeOneTimeKeys(olmSession)
- store.saveOlmAccount()
+ store.doWithOlmAccount { olmAccount ->
+ olmAccount.removeOneTimeKeys(olmSession)
+ store.saveOlmAccount()
+ }
} catch (e: Exception) {
- Timber.e(e, "## createInboundSession() : removeOneTimeKeys failed")
+ Timber.tag(loggerTag.value).e(e, "## createInboundSession() : removeOneTimeKeys failed")
}
- Timber.v("## createInboundSession() : ciphertext: $ciphertext")
+ Timber.tag(loggerTag.value).v("## createInboundSession() : ciphertext: $ciphertext")
try {
val sha256 = olmUtility!!.sha256(URLEncoder.encode(ciphertext, "utf-8"))
- Timber.v("## createInboundSession() :ciphertext: SHA256: $sha256")
+ Timber.tag(loggerTag.value).v("## createInboundSession() :ciphertext: SHA256: $sha256")
} catch (e: Exception) {
- Timber.e(e, "## createInboundSession() :ciphertext: cannot encode ciphertext")
+ Timber.tag(loggerTag.value).e(e, "## createInboundSession() :ciphertext: cannot encode ciphertext")
}
val olmMessage = OlmMessage()
@@ -324,9 +350,9 @@ internal class MXOlmDevice @Inject constructor(
// This counts as a received message: set last received message time to now
olmSessionWrapper.onMessageReceived()
- store.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
+ olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
} catch (e: Exception) {
- Timber.e(e, "## createInboundSession() : decryptMessage failed")
+ Timber.tag(loggerTag.value).e(e, "## createInboundSession() : decryptMessage failed")
}
val res = HashMap()
@@ -343,7 +369,7 @@ internal class MXOlmDevice @Inject constructor(
return res
} catch (e: Exception) {
- Timber.e(e, "## createInboundSession() : OlmSession creation failed")
+ Timber.tag(loggerTag.value).e(e, "## createInboundSession() : OlmSession creation failed")
olmSession?.releaseSession()
}
@@ -357,8 +383,8 @@ internal class MXOlmDevice @Inject constructor(
* @param theirDeviceIdentityKey the Curve25519 identity key for the remote device.
* @return a list of known session ids for the device.
*/
- fun getSessionIds(theirDeviceIdentityKey: String): List? {
- return store.getDeviceSessionIds(theirDeviceIdentityKey)
+ fun getSessionIds(theirDeviceIdentityKey: String): List {
+ return olmSessionStore.getDeviceSessionIds(theirDeviceIdentityKey)
}
/**
@@ -368,7 +394,7 @@ internal class MXOlmDevice @Inject constructor(
* @return the session id, or null if no established session.
*/
fun getSessionId(theirDeviceIdentityKey: String): String? {
- return store.getLastUsedSessionId(theirDeviceIdentityKey)
+ return olmSessionStore.getLastUsedSessionId(theirDeviceIdentityKey)
}
/**
@@ -379,30 +405,30 @@ internal class MXOlmDevice @Inject constructor(
* @param payloadString the payload to be encrypted and sent
* @return the cipher text
*/
- fun encryptMessage(theirDeviceIdentityKey: String, sessionId: String, payloadString: String): Map? {
- var res: MutableMap? = null
- val olmMessage: OlmMessage
+ suspend fun encryptMessage(theirDeviceIdentityKey: String, sessionId: String, payloadString: String): Map? {
val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId)
if (olmSessionWrapper != null) {
try {
- Timber.v("## encryptMessage() : olmSession.sessionIdentifier: $sessionId")
- // Timber.v("## encryptMessage() : payloadString: " + payloadString);
+ Timber.tag(loggerTag.value).v("## encryptMessage() : olmSession.sessionIdentifier: $sessionId")
- olmMessage = olmSessionWrapper.olmSession.encryptMessage(payloadString)
- store.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
- res = HashMap()
-
- res["body"] = olmMessage.mCipherText
- res["type"] = olmMessage.mType
- } catch (e: Exception) {
- Timber.e(e, "## encryptMessage() : failed")
+ val olmMessage = olmSessionWrapper.mutex.withLock {
+ olmSessionWrapper.olmSession.encryptMessage(payloadString)
+ }
+ return mapOf(
+ "body" to olmMessage.mCipherText,
+ "type" to olmMessage.mType,
+ ).also {
+ olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
+ }
+ } catch (e: Throwable) {
+ Timber.tag(loggerTag.value).e(e, "## encryptMessage() : failed to encrypt olm with device|session:$theirDeviceIdentityKey|$sessionId")
+ return null
}
} else {
- Timber.e("## encryptMessage() : Failed to encrypt unknown session $sessionId")
+ Timber.tag(loggerTag.value).e("## encryptMessage() : Failed to encrypt unknown session $sessionId")
+ return null
}
-
- return res
}
/**
@@ -414,7 +440,8 @@ internal class MXOlmDevice @Inject constructor(
* @param sessionId the id of the active session.
* @return the decrypted payload.
*/
- fun decryptMessage(ciphertext: String, messageType: Int, sessionId: String, theirDeviceIdentityKey: String): String? {
+ @kotlin.jvm.Throws
+ suspend fun decryptMessage(ciphertext: String, messageType: Int, sessionId: String, theirDeviceIdentityKey: String): String? {
var payloadString: String? = null
val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId)
@@ -424,13 +451,13 @@ internal class MXOlmDevice @Inject constructor(
olmMessage.mCipherText = ciphertext
olmMessage.mType = messageType.toLong()
- try {
- payloadString = olmSessionWrapper.olmSession.decryptMessage(olmMessage)
- olmSessionWrapper.onMessageReceived()
- store.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
- } catch (e: Exception) {
- Timber.e(e, "## decryptMessage() : decryptMessage failed")
- }
+ payloadString =
+ olmSessionWrapper.mutex.withLock {
+ olmSessionWrapper.olmSession.decryptMessage(olmMessage).also {
+ olmSessionWrapper.onMessageReceived()
+ }
+ }
+ olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
}
return payloadString
@@ -469,7 +496,7 @@ internal class MXOlmDevice @Inject constructor(
store.storeCurrentOutboundGroupSessionForRoom(roomId, session)
return session.sessionIdentifier()
} catch (e: Exception) {
- Timber.e(e, "createOutboundGroupSession")
+ Timber.tag(loggerTag.value).e(e, "createOutboundGroupSession")
session?.releaseSession()
}
@@ -521,7 +548,7 @@ internal class MXOlmDevice @Inject constructor(
try {
return outboundGroupSessionCache[sessionId]!!.groupSession.sessionKey()
} catch (e: Exception) {
- Timber.e(e, "## getSessionKey() : failed")
+ Timber.tag(loggerTag.value).e(e, "## getSessionKey() : failed")
}
}
return null
@@ -550,8 +577,8 @@ internal class MXOlmDevice @Inject constructor(
if (sessionId.isNotEmpty() && payloadString.isNotEmpty()) {
try {
return outboundGroupSessionCache[sessionId]!!.groupSession.encryptMessage(payloadString)
- } catch (e: Exception) {
- Timber.e(e, "## encryptGroupMessage() : failed")
+ } catch (e: Throwable) {
+ Timber.tag(loggerTag.value).e(e, "## encryptGroupMessage() : failed")
}
}
return null
@@ -578,52 +605,64 @@ internal class MXOlmDevice @Inject constructor(
forwardingCurve25519KeyChain: List,
keysClaimed: Map,
exportFormat: Boolean): Boolean {
- val session = OlmInboundGroupSessionWrapper2(sessionKey, exportFormat)
- runCatching { getInboundGroupSession(sessionId, senderKey, roomId) }
- .fold(
- {
- // If we already have this session, consider updating it
- Timber.e("## addInboundGroupSession() : Update for megolm session $senderKey/$sessionId")
+ val candidateSession = OlmInboundGroupSessionWrapper2(sessionKey, exportFormat)
+ val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) }
+ val existingSession = existingSessionHolder?.wrapper
+ // If we have an existing one we should check if the new one is not better
+ if (existingSession != null) {
+ Timber.tag(loggerTag.value).d("## addInboundGroupSession() check if known session is better than candidate session")
+ try {
+ val existingFirstKnown = existingSession.firstKnownIndex ?: return false.also {
+ // This is quite unexpected, could throw if native was released?
+ Timber.tag(loggerTag.value).e("## addInboundGroupSession() null firstKnownIndex on existing session")
+ candidateSession.olmInboundGroupSession?.releaseSession()
+ // Probably should discard it?
+ }
+ val newKnownFirstIndex = candidateSession.firstKnownIndex
+ // If our existing session is better we keep it
+ if (newKnownFirstIndex != null && existingFirstKnown <= newKnownFirstIndex) {
+ Timber.tag(loggerTag.value).d("## addInboundGroupSession() : ignore session our is better $senderKey/$sessionId")
+ candidateSession.olmInboundGroupSession?.releaseSession()
+ return false
+ }
+ } catch (failure: Throwable) {
+ Timber.tag(loggerTag.value).e("## addInboundGroupSession() Failed to add inbound: ${failure.localizedMessage}")
+ candidateSession.olmInboundGroupSession?.releaseSession()
+ return false
+ }
+ }
- val existingFirstKnown = it.firstKnownIndex!!
- val newKnownFirstIndex = session.firstKnownIndex
+ Timber.tag(loggerTag.value).d("## addInboundGroupSession() : Candidate session should be added $senderKey/$sessionId")
- // If our existing session is better we keep it
- if (newKnownFirstIndex != null && existingFirstKnown <= newKnownFirstIndex) {
- session.olmInboundGroupSession?.releaseSession()
- return false
- }
- },
- {
- // Nothing to do in case of error
- }
- )
-
- // sanity check
- if (null == session.olmInboundGroupSession) {
- Timber.e("## addInboundGroupSession : invalid session")
+ // sanity check on the new session
+ val candidateOlmInboundSession = candidateSession.olmInboundGroupSession
+ if (null == candidateOlmInboundSession) {
+ Timber.tag(loggerTag.value).e("## addInboundGroupSession : invalid session ")
return false
}
try {
- if (session.olmInboundGroupSession!!.sessionIdentifier() != sessionId) {
- Timber.e("## addInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey")
- session.olmInboundGroupSession!!.releaseSession()
+ if (candidateOlmInboundSession.sessionIdentifier() != sessionId) {
+ Timber.tag(loggerTag.value).e("## addInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey")
+ candidateOlmInboundSession.releaseSession()
return false
}
- } catch (e: Exception) {
- session.olmInboundGroupSession?.releaseSession()
- Timber.e(e, "## addInboundGroupSession : sessionIdentifier() failed")
+ } catch (e: Throwable) {
+ candidateOlmInboundSession.releaseSession()
+ Timber.tag(loggerTag.value).e(e, "## addInboundGroupSession : sessionIdentifier() failed")
return false
}
- session.senderKey = senderKey
- session.roomId = roomId
- session.keysClaimed = keysClaimed
- session.forwardingCurve25519KeyChain = forwardingCurve25519KeyChain
+ candidateSession.senderKey = senderKey
+ candidateSession.roomId = roomId
+ candidateSession.keysClaimed = keysClaimed
+ candidateSession.forwardingCurve25519KeyChain = forwardingCurve25519KeyChain
- inboundGroupSessionStore.storeInBoundGroupSession(session, sessionId, senderKey)
-// store.storeInboundGroupSessions(listOf(session))
+ if (existingSession != null) {
+ inboundGroupSessionStore.replaceGroupSession(existingSessionHolder, InboundGroupSessionHolder(candidateSession), sessionId, senderKey)
+ } else {
+ inboundGroupSessionStore.storeInBoundGroupSession(InboundGroupSessionHolder(candidateSession), sessionId, senderKey)
+ }
return true
}
@@ -638,57 +677,70 @@ internal class MXOlmDevice @Inject constructor(
val sessions = ArrayList(megolmSessionsData.size)
for (megolmSessionData in megolmSessionsData) {
- val sessionId = megolmSessionData.sessionId
- val senderKey = megolmSessionData.senderKey
+ val sessionId = megolmSessionData.sessionId ?: continue
+ val senderKey = megolmSessionData.senderKey ?: continue
val roomId = megolmSessionData.roomId
- var session: OlmInboundGroupSessionWrapper2? = null
+ var candidateSessionToImport: OlmInboundGroupSessionWrapper2? = null
try {
- session = OlmInboundGroupSessionWrapper2(megolmSessionData)
+ candidateSessionToImport = OlmInboundGroupSessionWrapper2(megolmSessionData)
} catch (e: Exception) {
- Timber.e(e, "## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId")
+ Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId")
}
// sanity check
- if (session?.olmInboundGroupSession == null) {
- Timber.e("## importInboundGroupSession : invalid session")
+ if (candidateSessionToImport?.olmInboundGroupSession == null) {
+ Timber.tag(loggerTag.value).e("## importInboundGroupSession : invalid session")
continue
}
+ val candidateOlmInboundGroupSession = candidateSessionToImport.olmInboundGroupSession
try {
- if (session.olmInboundGroupSession?.sessionIdentifier() != sessionId) {
- Timber.e("## importInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey")
- if (session.olmInboundGroupSession != null) session.olmInboundGroupSession!!.releaseSession()
+ if (candidateOlmInboundGroupSession?.sessionIdentifier() != sessionId) {
+ Timber.tag(loggerTag.value).e("## importInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey")
+ candidateOlmInboundGroupSession?.releaseSession()
continue
}
} catch (e: Exception) {
- Timber.e(e, "## importInboundGroupSession : sessionIdentifier() failed")
- session.olmInboundGroupSession!!.releaseSession()
+ Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession : sessionIdentifier() failed")
+ candidateOlmInboundGroupSession?.releaseSession()
continue
}
- runCatching { getInboundGroupSession(sessionId, senderKey, roomId) }
- .fold(
- {
- // If we already have this session, consider updating it
- Timber.e("## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId")
+ val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) }
+ val existingSession = existingSessionHolder?.wrapper
- // For now we just ignore updates. TODO: implement something here
- if (it.firstKnownIndex!! <= session.firstKnownIndex!!) {
- // Ignore this, keep existing
- session.olmInboundGroupSession!!.releaseSession()
- } else {
- sessions.add(session)
- }
- Unit
- },
- {
- // Session does not already exist, add it
- sessions.add(session)
- }
+ if (existingSession == null) {
+ // Session does not already exist, add it
+ Timber.tag(loggerTag.value).d("## importInboundGroupSession() : importing new megolm session $senderKey/$sessionId")
+ sessions.add(candidateSessionToImport)
+ } else {
+ Timber.tag(loggerTag.value).e("## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId")
+ val existingFirstKnown = tryOrNull { existingSession.firstKnownIndex }
+ val candidateFirstKnownIndex = tryOrNull { candidateSessionToImport.firstKnownIndex }
- )
+ if (existingFirstKnown == null || candidateFirstKnownIndex == null) {
+ // should not happen?
+ candidateSessionToImport.olmInboundGroupSession?.releaseSession()
+ Timber.tag(loggerTag.value)
+ .w("## importInboundGroupSession() : Can't check session null index $existingFirstKnown/$candidateFirstKnownIndex")
+ } else {
+ if (existingFirstKnown <= candidateSessionToImport.firstKnownIndex!!) {
+ // Ignore this, keep existing
+ candidateOlmInboundGroupSession.releaseSession()
+ } else {
+ // update cache with better session
+ inboundGroupSessionStore.replaceGroupSession(
+ existingSessionHolder,
+ InboundGroupSessionHolder(candidateSessionToImport),
+ sessionId,
+ senderKey
+ )
+ sessions.add(candidateSessionToImport)
+ }
+ }
+ }
}
store.storeInboundGroupSessions(sessions)
@@ -696,18 +748,6 @@ internal class MXOlmDevice @Inject constructor(
return sessions
}
- /**
- * Remove an inbound group session
- *
- * @param sessionId the session identifier.
- * @param sessionKey base64-encoded secret key.
- */
- fun removeInboundGroupSession(sessionId: String?, sessionKey: String?) {
- if (null != sessionId && null != sessionKey) {
- store.removeInboundGroupSession(sessionId, sessionKey)
- }
- }
-
/**
* Decrypt a received message with an inbound group session.
*
@@ -719,19 +759,24 @@ internal class MXOlmDevice @Inject constructor(
* @return the decrypting result. Nil if the sessionId is unknown.
*/
@Throws(MXCryptoError::class)
- fun decryptGroupMessage(body: String,
- roomId: String,
- timeline: String?,
- sessionId: String,
- senderKey: String): OlmDecryptionResult {
- val session = getInboundGroupSession(sessionId, senderKey, roomId)
+ suspend fun decryptGroupMessage(body: String,
+ roomId: String,
+ timeline: String?,
+ sessionId: String,
+ senderKey: String): OlmDecryptionResult {
+ val sessionHolder = getInboundGroupSession(sessionId, senderKey, roomId)
+ val wrapper = sessionHolder.wrapper
+ val inboundGroupSession = wrapper.olmInboundGroupSession
+ ?: throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, "Session is null")
// Check that the room id matches the original one for the session. This stops
// the HS pretending a message was targeting a different room.
- if (roomId == session.roomId) {
+ if (roomId == wrapper.roomId) {
val decryptResult = try {
- session.olmInboundGroupSession!!.decryptMessage(body)
+ sessionHolder.mutex.withLock {
+ inboundGroupSession.decryptMessage(body)
+ }
} catch (e: OlmException) {
- Timber.e(e, "## decryptGroupMessage () : decryptMessage failed")
+ Timber.tag(loggerTag.value).e(e, "## decryptGroupMessage () : decryptMessage failed")
throw MXCryptoError.OlmError(e)
}
@@ -742,32 +787,32 @@ internal class MXOlmDevice @Inject constructor(
if (timelineSet.contains(messageIndexKey)) {
val reason = String.format(MXCryptoError.DUPLICATE_MESSAGE_INDEX_REASON, decryptResult.mIndex)
- Timber.e("## decryptGroupMessage() : $reason")
+ Timber.tag(loggerTag.value).e("## decryptGroupMessage() : $reason")
throw MXCryptoError.Base(MXCryptoError.ErrorType.DUPLICATED_MESSAGE_INDEX, reason)
}
timelineSet.add(messageIndexKey)
}
- inboundGroupSessionStore.storeInBoundGroupSession(session, sessionId, senderKey)
+ inboundGroupSessionStore.storeInBoundGroupSession(sessionHolder, sessionId, senderKey)
val payload = try {
val adapter = MoshiProvider.providesMoshi().adapter(JSON_DICT_PARAMETERIZED_TYPE)
val payloadString = convertFromUTF8(decryptResult.mDecryptedMessage)
adapter.fromJson(payloadString)
} catch (e: Exception) {
- Timber.e("## decryptGroupMessage() : fails to parse the payload")
+ Timber.tag(loggerTag.value).e("## decryptGroupMessage() : fails to parse the payload")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_DECRYPTED_FORMAT, MXCryptoError.BAD_DECRYPTED_FORMAT_TEXT_REASON)
}
return OlmDecryptionResult(
payload,
- session.keysClaimed,
+ wrapper.keysClaimed,
senderKey,
- session.forwardingCurve25519KeyChain
+ wrapper.forwardingCurve25519KeyChain
)
} else {
- val reason = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, session.roomId)
- Timber.e("## decryptGroupMessage() : $reason")
+ val reason = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, wrapper.roomId)
+ Timber.tag(loggerTag.value).e("## decryptGroupMessage() : $reason")
throw MXCryptoError.Base(MXCryptoError.ErrorType.INBOUND_SESSION_MISMATCH_ROOM_ID, reason)
}
}
@@ -819,7 +864,7 @@ internal class MXOlmDevice @Inject constructor(
private fun getSessionForDevice(theirDeviceIdentityKey: String, sessionId: String): OlmSessionWrapper? {
// sanity check
return if (theirDeviceIdentityKey.isEmpty() || sessionId.isEmpty()) null else {
- store.getDeviceSession(sessionId, theirDeviceIdentityKey)
+ olmSessionStore.getDeviceSession(sessionId, theirDeviceIdentityKey)
}
}
@@ -832,25 +877,26 @@ internal class MXOlmDevice @Inject constructor(
* @param senderKey the base64-encoded curve25519 key of the sender.
* @return the inbound group session.
*/
- fun getInboundGroupSession(sessionId: String?, senderKey: String?, roomId: String?): OlmInboundGroupSessionWrapper2 {
+ fun getInboundGroupSession(sessionId: String?, senderKey: String?, roomId: String?): InboundGroupSessionHolder {
if (sessionId.isNullOrBlank() || senderKey.isNullOrBlank()) {
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_SENDER_KEY, MXCryptoError.ERROR_MISSING_PROPERTY_REASON)
}
- val session = inboundGroupSessionStore.getInboundGroupSession(sessionId, senderKey)
+ val holder = inboundGroupSessionStore.getInboundGroupSession(sessionId, senderKey)
+ val session = holder?.wrapper
if (session != null) {
// Check that the room id matches the original one for the session. This stops
// the HS pretending a message was targeting a different room.
if (roomId != session.roomId) {
val errorDescription = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, session.roomId)
- Timber.e("## getInboundGroupSession() : $errorDescription")
+ Timber.tag(loggerTag.value).e("## getInboundGroupSession() : $errorDescription")
throw MXCryptoError.Base(MXCryptoError.ErrorType.INBOUND_SESSION_MISMATCH_ROOM_ID, errorDescription)
} else {
- return session
+ return holder
}
} else {
- Timber.w("## getInboundGroupSession() : Cannot retrieve inbound group session $sessionId")
+ Timber.tag(loggerTag.value).w("## getInboundGroupSession() : UISI $sessionId")
throw MXCryptoError.Base(MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID, MXCryptoError.UNKNOWN_INBOUND_SESSION_ID_REASON)
}
}
@@ -866,4 +912,9 @@ internal class MXOlmDevice @Inject constructor(
fun hasInboundSessionKeys(roomId: String, senderKey: String, sessionId: String): Boolean {
return runCatching { getInboundGroupSession(sessionId, senderKey, roomId) }.isSuccess
}
+
+ @VisibleForTesting
+ fun clearOlmSessionCache() {
+ olmSessionStore.clear()
+ }
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmSessionStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmSessionStore.kt
new file mode 100644
index 0000000000..f4fbca6a0f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmSessionStore.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.crypto
+
+import org.matrix.android.sdk.api.logger.LoggerTag
+import org.matrix.android.sdk.internal.crypto.model.OlmSessionWrapper
+import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
+import org.matrix.olm.OlmSession
+import timber.log.Timber
+import javax.inject.Inject
+
+private val loggerTag = LoggerTag("OlmSessionStore", LoggerTag.CRYPTO)
+
+/**
+ * Keep the used olm session in memory and load them from the data layer when needed
+ * Access is synchronized for thread safety
+ */
+internal class OlmSessionStore @Inject constructor(private val store: IMXCryptoStore) {
+ /**
+ * map of device key to list of olm sessions (it is possible to have several active sessions with a device)
+ */
+ private val olmSessions = HashMap>()
+
+ /**
+ * Store a session between our own device and another device.
+ * This will be called after the session has been created but also every time it has been used
+ * in order to persist the correct state for next run
+ * @param olmSessionWrapper the end-to-end session.
+ * @param deviceKey the public key of the other device.
+ */
+ @Synchronized
+ fun storeSession(olmSessionWrapper: OlmSessionWrapper, deviceKey: String) {
+ // This could be a newly created session or one that was just created
+ // Anyhow we should persist ratchet state for future app lifecycle
+ addNewSessionInCache(olmSessionWrapper, deviceKey)
+ store.storeSession(olmSessionWrapper, deviceKey)
+ }
+
+ /**
+ * Get all the Olm Sessions we are sharing with the given device.
+ *
+ * @param deviceKey the public key of the other device.
+ * @return A set of sessionId, or empty if device is not known
+ */
+ @Synchronized
+ fun getDeviceSessionIds(deviceKey: String): List {
+ // we need to get the persisted ids first
+ val persistedKnownSessions = store.getDeviceSessionIds(deviceKey)
+ .orEmpty()
+ .toMutableList()
+ // Do we have some in cache not yet persisted?
+ olmSessions.getOrPut(deviceKey) { mutableListOf() }.forEach { cached ->
+ getSafeSessionIdentifier(cached.olmSession)?.let { cachedSessionId ->
+ if (!persistedKnownSessions.contains(cachedSessionId)) {
+ persistedKnownSessions.add(cachedSessionId)
+ }
+ }
+ }
+ return persistedKnownSessions
+ }
+
+ /**
+ * Retrieve an end-to-end session between our own device and another
+ * device.
+ *
+ * @param sessionId the session Id.
+ * @param deviceKey the public key of the other device.
+ * @return the session wrapper if found
+ */
+ @Synchronized
+ fun getDeviceSession(sessionId: String, deviceKey: String): OlmSessionWrapper? {
+ // get from cache or load and add to cache
+ return internalGetSession(sessionId, deviceKey)
+ }
+
+ /**
+ * Retrieve the last used sessionId, regarding `lastReceivedMessageTs`, or null if no session exist
+ *
+ * @param deviceKey the public key of the other device.
+ * @return last used sessionId, or null if not found
+ */
+ @Synchronized
+ fun getLastUsedSessionId(deviceKey: String): String? {
+ // We want to avoid to load in memory old session if possible
+ val lastPersistedUsedSession = store.getLastUsedSessionId(deviceKey)
+ var candidate = lastPersistedUsedSession?.let { internalGetSession(it, deviceKey) }
+ // we should check if we have one in cache with a higher last message received?
+ olmSessions[deviceKey].orEmpty().forEach { inCache ->
+ if (inCache.lastReceivedMessageTs > (candidate?.lastReceivedMessageTs ?: 0L)) {
+ candidate = inCache
+ }
+ }
+
+ return candidate?.olmSession?.sessionIdentifier()
+ }
+
+ /**
+ * Release all sessions and clear cache
+ */
+ @Synchronized
+ fun clear() {
+ olmSessions.entries.onEach { entry ->
+ entry.value.onEach { it.olmSession.releaseSession() }
+ }
+ olmSessions.clear()
+ }
+
+ private fun internalGetSession(sessionId: String, deviceKey: String): OlmSessionWrapper? {
+ return getSessionInCache(sessionId, deviceKey)
+ ?: // deserialize from store
+ return store.getDeviceSession(sessionId, deviceKey)?.also {
+ addNewSessionInCache(it, deviceKey)
+ }
+ }
+
+ private fun getSessionInCache(sessionId: String, deviceKey: String): OlmSessionWrapper? {
+ return olmSessions[deviceKey]?.firstOrNull {
+ getSafeSessionIdentifier(it.olmSession) == sessionId
+ }
+ }
+
+ private fun getSafeSessionIdentifier(session: OlmSession): String? {
+ return try {
+ session.sessionIdentifier()
+ } catch (throwable: Throwable) {
+ Timber.tag(loggerTag.value).w("Failed to load sessionId from loaded olm session")
+ null
+ }
+ }
+
+ private fun addNewSessionInCache(session: OlmSessionWrapper, deviceKey: String) {
+ val sessionId = getSafeSessionIdentifier(session.olmSession) ?: return
+ olmSessions.getOrPut(deviceKey) { mutableListOf() }.let {
+ val existing = it.firstOrNull { getSafeSessionIdentifier(it.olmSession) == sessionId }
+ it.add(session)
+ // remove and release if was there but with different instance
+ if (existing != null && existing.olmSession != session.olmSession) {
+ // mm not sure when this could happen
+ // anyhow we should remove and release the one known
+ it.remove(existing)
+ existing.olmSession.releaseSession()
+ }
+ }
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt
index ab2ed04dfb..87c176612d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/EnsureOlmSessionsForDevicesAction.kt
@@ -16,14 +16,18 @@
package org.matrix.android.sdk.internal.crypto.actions
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.internal.crypto.MXOlmDevice
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.MXKey
import org.matrix.android.sdk.internal.crypto.model.MXOlmSessionResult
import org.matrix.android.sdk.internal.crypto.model.MXUsersDevicesMap
-import org.matrix.android.sdk.internal.crypto.model.toDebugString
import org.matrix.android.sdk.internal.crypto.tasks.ClaimOneTimeKeysForUsersDeviceTask
+import org.matrix.android.sdk.internal.session.SessionScope
import timber.log.Timber
import javax.inject.Inject
@@ -31,90 +35,90 @@ private const val ONE_TIME_KEYS_RETRY_COUNT = 3
private val loggerTag = LoggerTag("EnsureOlmSessionsForDevicesAction", LoggerTag.CRYPTO)
+@SessionScope
internal class EnsureOlmSessionsForDevicesAction @Inject constructor(
private val olmDevice: MXOlmDevice,
+ private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val oneTimeKeysForUsersDeviceTask: ClaimOneTimeKeysForUsersDeviceTask) {
+ private val ensureMutex = Mutex()
+
+ /**
+ * We want to synchronize a bit here, because we are iterating to check existing olm session and
+ * also adding some
+ */
suspend fun handle(devicesByUser: Map>, force: Boolean = false): MXUsersDevicesMap {
- val devicesWithoutSession = ArrayList()
+ ensureMutex.withLock {
+ val results = MXUsersDevicesMap()
+ val deviceList = devicesByUser.flatMap { it.value }
+ Timber.tag(loggerTag.value)
+ .d("ensure olm forced:$force for ${deviceList.joinToString { it.shortDebugString() }}")
+ val devicesToCreateSessionWith = mutableListOf()
+ if (force) {
+ // we take all devices and will query otk for them
+ devicesToCreateSessionWith.addAll(deviceList)
+ } else {
+ // only peek devices without active session
+ deviceList.forEach { deviceInfo ->
+ val deviceId = deviceInfo.deviceId
+ val userId = deviceInfo.userId
+ val key = deviceInfo.identityKey() ?: return@forEach Unit.also {
+ Timber.tag(loggerTag.value).w("Ignoring device ${deviceInfo.shortDebugString()} without identity key")
+ }
- val results = MXUsersDevicesMap()
-
- for ((userId, deviceList) in devicesByUser) {
- for (deviceInfo in deviceList) {
- val deviceId = deviceInfo.deviceId
- val key = deviceInfo.identityKey()
- if (key == null) {
- Timber.w("## CRYPTO | Ignoring device (${deviceInfo.userId}|$deviceId) without identity key")
- continue
- }
-
- val sessionId = olmDevice.getSessionId(key)
-
- if (sessionId.isNullOrEmpty() || force) {
- Timber.tag(loggerTag.value).d("Found no existing olm session (${deviceInfo.userId}|$deviceId) (force=$force)")
- devicesWithoutSession.add(deviceInfo)
- } else {
- Timber.tag(loggerTag.value).d("using olm session $sessionId for (${deviceInfo.userId}|$deviceId)")
- }
-
- val olmSessionResult = MXOlmSessionResult(deviceInfo, sessionId)
- results.setObject(userId, deviceId, olmSessionResult)
- }
- }
-
- Timber.tag(loggerTag.value).d("Devices without olm session (count:${devicesWithoutSession.size}) :" +
- " ${devicesWithoutSession.joinToString { "${it.userId}|${it.deviceId}" }}")
- if (devicesWithoutSession.size == 0) {
- return results
- }
-
- // Prepare the request for claiming one-time keys
- val usersDevicesToClaim = MXUsersDevicesMap()
-
- val oneTimeKeyAlgorithm = MXKey.KEY_SIGNED_CURVE_25519_TYPE
-
- for (device in devicesWithoutSession) {
- usersDevicesToClaim.setObject(device.userId, device.deviceId, oneTimeKeyAlgorithm)
- }
-
- // TODO: this has a race condition - if we try to send another message
- // while we are claiming a key, we will end up claiming two and setting up
- // two sessions.
- //
- // That should eventually resolve itself, but it's poor form.
-
- Timber.tag(loggerTag.value).i("claimOneTimeKeysForUsersDevices() : ${usersDevicesToClaim.toDebugString()}")
-
- val claimParams = ClaimOneTimeKeysForUsersDeviceTask.Params(usersDevicesToClaim)
- val oneTimeKeys = oneTimeKeysForUsersDeviceTask.executeRetry(claimParams, remainingRetry = ONE_TIME_KEYS_RETRY_COUNT)
- Timber.tag(loggerTag.value).v("claimOneTimeKeysForUsersDevices() : keysClaimResponse.oneTimeKeys: $oneTimeKeys")
- for ((userId, deviceInfos) in devicesByUser) {
- for (deviceInfo in deviceInfos) {
- var oneTimeKey: MXKey? = null
- val deviceIds = oneTimeKeys.getUserDeviceIds(userId)
- if (null != deviceIds) {
- for (deviceId in deviceIds) {
- val olmSessionResult = results.getObject(userId, deviceId)
- if (olmSessionResult?.sessionId != null && !force) {
- // We already have a result for this device
- continue
- }
- val key = oneTimeKeys.getObject(userId, deviceId)
- if (key?.type == oneTimeKeyAlgorithm) {
- oneTimeKey = key
- }
- if (oneTimeKey == null) {
- Timber.tag(loggerTag.value).d("No one time key for $userId|$deviceId")
- continue
- }
- // Update the result for this device in results
- olmSessionResult?.sessionId = verifyKeyAndStartSession(oneTimeKey, userId, deviceInfo)
+ // is there a session that as been already used?
+ val sessionId = olmDevice.getSessionId(key)
+ if (sessionId.isNullOrEmpty()) {
+ Timber.tag(loggerTag.value).d("Found no existing olm session ${deviceInfo.shortDebugString()} add to claim list")
+ devicesToCreateSessionWith.add(deviceInfo)
+ } else {
+ Timber.tag(loggerTag.value).d("using olm session $sessionId for (${deviceInfo.userId}|$deviceId)")
+ val olmSessionResult = MXOlmSessionResult(deviceInfo, sessionId)
+ results.setObject(userId, deviceId, olmSessionResult)
}
}
}
+
+ if (devicesToCreateSessionWith.isEmpty()) {
+ // no session to create
+ return results
+ }
+ val usersDevicesToClaim = MXUsersDevicesMap().apply {
+ devicesToCreateSessionWith.forEach {
+ setObject(it.userId, it.deviceId, MXKey.KEY_SIGNED_CURVE_25519_TYPE)
+ }
+ }
+
+ // Let's now claim one time keys
+ val claimParams = ClaimOneTimeKeysForUsersDeviceTask.Params(usersDevicesToClaim)
+ val oneTimeKeys = withContext(coroutineDispatchers.io) {
+ oneTimeKeysForUsersDeviceTask.executeRetry(claimParams, ONE_TIME_KEYS_RETRY_COUNT)
+ }
+
+ // let now start olm session using the new otks
+ devicesToCreateSessionWith.forEach { deviceInfo ->
+ val userId = deviceInfo.userId
+ val deviceId = deviceInfo.deviceId
+ // Did we get an OTK
+ val oneTimeKey = oneTimeKeys.getObject(userId, deviceId)
+ if (oneTimeKey == null) {
+ Timber.tag(loggerTag.value).d("No otk for ${deviceInfo.shortDebugString()}")
+ } else if (oneTimeKey.type != MXKey.KEY_SIGNED_CURVE_25519_TYPE) {
+ Timber.tag(loggerTag.value).d("Bad otk type (${oneTimeKey.type}) for ${deviceInfo.shortDebugString()}")
+ } else {
+ val olmSessionId = verifyKeyAndStartSession(oneTimeKey, userId, deviceInfo)
+ if (olmSessionId != null) {
+ val olmSessionResult = MXOlmSessionResult(deviceInfo, olmSessionId)
+ results.setObject(userId, deviceId, olmSessionResult)
+ } else {
+ Timber
+ .tag(loggerTag.value)
+ .d("## CRYPTO | cant unwedge failed to create outbound ${deviceInfo.shortDebugString()}")
+ }
+ }
+ }
+ return results
}
- return results
}
private fun verifyKeyAndStartSession(oneTimeKey: MXKey, userId: String, deviceInfo: CryptoDeviceInfo): String? {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt
index 165f200bac..4e158602c8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MessageEncrypter.kt
@@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.crypto.actions
+import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_OLM
import org.matrix.android.sdk.internal.crypto.MXOlmDevice
@@ -28,6 +29,8 @@ import org.matrix.android.sdk.internal.util.convertToUTF8
import timber.log.Timber
import javax.inject.Inject
+private val loggerTag = LoggerTag("MessageEncrypter", LoggerTag.CRYPTO)
+
internal class MessageEncrypter @Inject constructor(
@UserId
private val userId: String,
@@ -42,7 +45,7 @@ internal class MessageEncrypter @Inject constructor(
* @param deviceInfos list of device infos to encrypt for.
* @return the content for an m.room.encrypted event.
*/
- fun encryptMessage(payloadFields: Content, deviceInfos: List): EncryptedMessage {
+ suspend fun encryptMessage(payloadFields: Content, deviceInfos: List): EncryptedMessage {
val deviceInfoParticipantKey = deviceInfos.associateBy { it.identityKey()!! }
val payloadJson = payloadFields.toMutableMap()
@@ -66,7 +69,7 @@ internal class MessageEncrypter @Inject constructor(
val sessionId = olmDevice.getSessionId(deviceKey)
if (!sessionId.isNullOrEmpty()) {
- Timber.v("Using sessionid $sessionId for device $deviceKey")
+ Timber.tag(loggerTag.value).d("Using sessionid $sessionId for device $deviceKey")
payloadJson["recipient"] = deviceInfo.userId
payloadJson["recipient_keys"] = mapOf("ed25519" to deviceInfo.fingerprint()!!)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt
index 79c7608cbf..b6c1d99aa5 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXDecrypting.kt
@@ -36,7 +36,7 @@ internal interface IMXDecrypting {
* @return the decryption information, or an error
*/
@Throws(MXCryptoError::class)
- fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult
+ suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult
/**
* Handle a key event.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt
index 1fd5061a65..6f488def0a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXGroupEncryption.kt
@@ -45,7 +45,7 @@ internal interface IMXGroupEncryption {
*
* @return true in case of success
*/
- suspend fun reshareKey(sessionId: String,
+ suspend fun reshareKey(groupSessionId: String,
userId: String,
deviceId: String,
senderKey: String): Boolean
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt
index 2ee24dfbb0..e94daa0e76 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.crypto.algorithms.megolm
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.withLock
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
@@ -71,7 +72,7 @@ internal class MXMegolmDecryption(private val userId: String,
// private var pendingEvents: MutableMap>> = HashMap()
@Throws(MXCryptoError::class)
- override fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
+ override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
// If cross signing is enabled, we don't send request until the keys are trusted
// There could be a race effect here when xsigning is enabled, we should ensure that keys was downloaded once
val requestOnFail = cryptoStore.getMyCrossSigningInfo()?.isTrusted() == true
@@ -79,7 +80,7 @@ internal class MXMegolmDecryption(private val userId: String,
}
@Throws(MXCryptoError::class)
- private fun decryptEvent(event: Event, timeline: String, requestKeysOnFail: Boolean): MXEventDecryptionResult {
+ private suspend fun decryptEvent(event: Event, timeline: String, requestKeysOnFail: Boolean): MXEventDecryptionResult {
Timber.tag(loggerTag.value).v("decryptEvent ${event.eventId}, requestKeysOnFail:$requestKeysOnFail")
if (event.roomId.isNullOrBlank()) {
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_FIELDS, MXCryptoError.MISSING_FIELDS_REASON)
@@ -345,7 +346,22 @@ internal class MXMegolmDecryption(private val userId: String,
return
}
val userId = request.userId ?: return
+
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
+ val body = request.requestBody
+ val sessionHolder = try {
+ olmDevice.getInboundGroupSession(body.sessionId, body.senderKey, body.roomId)
+ } catch (failure: Throwable) {
+ Timber.tag(loggerTag.value).e(failure, "shareKeysWithDevice: failed to get session for request $body")
+ return@launch
+ }
+
+ val export = sessionHolder.mutex.withLock {
+ sessionHolder.wrapper.exportKeys()
+ } ?: return@launch Unit.also {
+ Timber.tag(loggerTag.value).e("shareKeysWithDevice: failed to export group session ${body.sessionId}")
+ }
+
runCatching { deviceListManager.downloadKeys(listOf(userId), false) }
.mapCatching {
val deviceId = request.deviceId
@@ -355,7 +371,6 @@ internal class MXMegolmDecryption(private val userId: String,
} else {
val devicesByUser = mapOf(userId to listOf(deviceInfo))
val usersDeviceMap = ensureOlmSessionsForDevicesAction.handle(devicesByUser)
- val body = request.requestBody
val olmSessionResult = usersDeviceMap.getObject(userId, deviceId)
if (olmSessionResult?.sessionId == null) {
// no session with this device, probably because there
@@ -365,19 +380,10 @@ internal class MXMegolmDecryption(private val userId: String,
}
Timber.tag(loggerTag.value).i("shareKeysWithDevice() : sharing session ${body.sessionId} with device $userId:$deviceId")
- val payloadJson = mutableMapOf("type" to EventType.FORWARDED_ROOM_KEY)
- runCatching { olmDevice.getInboundGroupSession(body.sessionId, body.senderKey, body.roomId) }
- .fold(
- {
- // TODO
- payloadJson["content"] = it.exportKeys() ?: ""
- },
- {
- // TODO
- Timber.tag(loggerTag.value).e(it, "shareKeysWithDevice: failed to get session for request $body")
- }
-
- )
+ val payloadJson = mapOf(
+ "type" to EventType.FORWARDED_ROOM_KEY,
+ "content" to export
+ )
val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo))
val sendToDeviceMap = MXUsersDevicesMap()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt
index 389036a1f8..cf9733dc2d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt
@@ -18,6 +18,8 @@ package org.matrix.android.sdk.internal.crypto.algorithms.megolm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
@@ -88,7 +90,7 @@ internal class MXMegolmEncryption(
Timber.tag(loggerTag.value).v("encryptEventContent : getDevicesInRoom")
val devices = getDevicesInRoom(userIds)
Timber.tag(loggerTag.value).d("encrypt event in room=$roomId - devices count in room ${devices.allowedDevices.toDebugCount()}")
- Timber.tag(loggerTag.value).v("encryptEventContent ${System.currentTimeMillis() - ts}: getDevicesInRoom ${devices.allowedDevices.map}")
+ Timber.tag(loggerTag.value).v("encryptEventContent ${System.currentTimeMillis() - ts}: getDevicesInRoom ${devices.allowedDevices.toDebugString()}")
val outboundSession = ensureOutboundSession(devices.allowedDevices)
return encryptContent(outboundSession, eventType, eventContent)
@@ -142,8 +144,9 @@ internal class MXMegolmEncryption(
Timber.tag(loggerTag.value).v("prepareNewSessionInRoom() ")
val sessionId = olmDevice.createOutboundGroupSessionForRoom(roomId)
- val keysClaimedMap = HashMap()
- keysClaimedMap["ed25519"] = olmDevice.deviceEd25519Key!!
+ val keysClaimedMap = mapOf(
+ "ed25519" to olmDevice.deviceEd25519Key!!
+ )
olmDevice.addInboundGroupSession(sessionId!!, olmDevice.getSessionKey(sessionId)!!, roomId, olmDevice.deviceCurve25519Key!!,
emptyList(), keysClaimedMap, false)
@@ -303,11 +306,13 @@ internal class MXMegolmEncryption(
Timber.tag(loggerTag.value).d("sending to device room key for ${session.sessionId} to ${contentMap.toDebugString()}")
val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, contentMap)
try {
- sendToDeviceTask.execute(sendToDeviceParams)
+ withContext(coroutineDispatchers.io) {
+ sendToDeviceTask.execute(sendToDeviceParams)
+ }
Timber.tag(loggerTag.value).i("shareUserDevicesKey() : sendToDevice succeeds after ${System.currentTimeMillis() - t0} ms")
} catch (failure: Throwable) {
// What to do here...
- Timber.tag(loggerTag.value).e("shareUserDevicesKey() : Failed to share session <${session.sessionId}> with $devicesByUser ")
+ Timber.tag(loggerTag.value).e("shareUserDevicesKey() : Failed to share <${session.sessionId}>")
}
} else {
Timber.tag(loggerTag.value).i("shareUserDevicesKey() : no need to share key")
@@ -346,9 +351,12 @@ internal class MXMegolmEncryption(
}
)
try {
- sendToDeviceTask.execute(params)
+ withContext(coroutineDispatchers.io) {
+ sendToDeviceTask.execute(params)
+ }
} catch (failure: Throwable) {
- Timber.tag(loggerTag.value).e("notifyKeyWithHeld() : Failed to notify withheld key for $targets session: $sessionId ")
+ Timber.tag(loggerTag.value)
+ .e("notifyKeyWithHeld() :$sessionId Failed to send withheld ${targets.map { "${it.userId}|${it.deviceId}" }}")
}
}
@@ -432,20 +440,20 @@ internal class MXMegolmEncryption(
}
}
- override suspend fun reshareKey(sessionId: String,
+ override suspend fun reshareKey(groupSessionId: String,
userId: String,
deviceId: String,
senderKey: String): Boolean {
- Timber.tag(loggerTag.value).i("process reshareKey for $sessionId to $userId:$deviceId")
+ Timber.tag(loggerTag.value).i("process reshareKey for $groupSessionId to $userId:$deviceId")
val deviceInfo = cryptoStore.getUserDevice(userId, deviceId) ?: return false
.also { Timber.tag(loggerTag.value).w("reshareKey: Device not found") }
// Get the chain index of the key we previously sent this device
- val wasSessionSharedWithUser = cryptoStore.getSharedSessionInfo(roomId, sessionId, deviceInfo)
+ val wasSessionSharedWithUser = cryptoStore.getSharedSessionInfo(roomId, groupSessionId, deviceInfo)
if (!wasSessionSharedWithUser.found) {
// This session was never shared with this user
// Send a room key with held
- notifyKeyWithHeld(listOf(UserDevice(userId, deviceId)), sessionId, senderKey, WithHeldCode.UNAUTHORISED)
+ notifyKeyWithHeld(listOf(UserDevice(userId, deviceId)), groupSessionId, senderKey, WithHeldCode.UNAUTHORISED)
Timber.tag(loggerTag.value).w("reshareKey: ERROR : Never shared megolm with this device")
return false
}
@@ -456,42 +464,47 @@ internal class MXMegolmEncryption(
}
val devicesByUser = mapOf(userId to listOf(deviceInfo))
- val usersDeviceMap = ensureOlmSessionsForDevicesAction.handle(devicesByUser)
- val olmSessionResult = usersDeviceMap.getObject(userId, deviceId)
- olmSessionResult?.sessionId // no session with this device, probably because there were no one-time keys.
- // ensureOlmSessionsForDevicesAction has already done the logging, so just skip it.
- ?: return false.also {
- Timber.tag(loggerTag.value).w("reshareKey: no session with this device, probably because there were no one-time keys")
- }
+ val usersDeviceMap = try {
+ ensureOlmSessionsForDevicesAction.handle(devicesByUser)
+ } catch (failure: Throwable) {
+ null
+ }
+ val olmSessionResult = usersDeviceMap?.getObject(userId, deviceId)
+ if (olmSessionResult?.sessionId == null) {
+ Timber.tag(loggerTag.value).w("reshareKey: no session with this device, probably because there were no one-time keys")
+ return false
+ }
+ Timber.tag(loggerTag.value).i(" reshareKey: $groupSessionId:$chainIndex with device $userId:$deviceId using session ${olmSessionResult.sessionId}")
- Timber.tag(loggerTag.value).i(" reshareKey: sharing keys for session $senderKey|$sessionId:$chainIndex with device $userId:$deviceId")
+ val sessionHolder = try {
+ olmDevice.getInboundGroupSession(groupSessionId, senderKey, roomId)
+ } catch (failure: Throwable) {
+ Timber.tag(loggerTag.value).e(failure, "shareKeysWithDevice: failed to get session $groupSessionId")
+ return false
+ }
- val payloadJson = mutableMapOf("type" to EventType.FORWARDED_ROOM_KEY)
+ val export = sessionHolder.mutex.withLock {
+ sessionHolder.wrapper.exportKeys()
+ } ?: return false.also {
+ Timber.tag(loggerTag.value).e("shareKeysWithDevice: failed to export group session $groupSessionId")
+ }
- runCatching { olmDevice.getInboundGroupSession(sessionId, senderKey, roomId) }
- .fold(
- {
- // TODO
- payloadJson["content"] = it.exportKeys(chainIndex.toLong()) ?: ""
- },
- {
- // TODO
- Timber.tag(loggerTag.value).e(it, "reshareKey: failed to get session $sessionId|$senderKey|$roomId")
- }
-
- )
+ val payloadJson = mapOf(
+ "type" to EventType.FORWARDED_ROOM_KEY,
+ "content" to export
+ )
val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo))
val sendToDeviceMap = MXUsersDevicesMap()
sendToDeviceMap.setObject(userId, deviceId, encodedPayload)
- Timber.tag(loggerTag.value).i("reshareKey() : sending session $sessionId to $userId:$deviceId")
+ Timber.tag(loggerTag.value).i("reshareKey() : sending session $groupSessionId to $userId:$deviceId")
val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap)
return try {
sendToDeviceTask.execute(sendToDeviceParams)
- Timber.tag(loggerTag.value).i("reshareKey() : successfully send <$sessionId> to $userId:$deviceId")
+ Timber.tag(loggerTag.value).i("reshareKey() : successfully send <$groupSessionId> to $userId:$deviceId")
true
} catch (failure: Throwable) {
- Timber.tag(loggerTag.value).e(failure, "reshareKey() : fail to send <$sessionId> to $userId:$deviceId")
+ Timber.tag(loggerTag.value).e(failure, "reshareKey() : fail to send <$groupSessionId> to $userId:$deviceId")
false
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt
index f1bca4fbc6..afa249801d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/olm/MXOlmDecryption.kt
@@ -16,6 +16,8 @@
package org.matrix.android.sdk.internal.crypto.algorithms.olm
+import kotlinx.coroutines.sync.withLock
+import org.matrix.android.sdk.api.logger.LoggerTag
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toModel
@@ -30,6 +32,7 @@ import org.matrix.android.sdk.internal.di.MoshiProvider
import org.matrix.android.sdk.internal.util.convertFromUTF8
import timber.log.Timber
+private val loggerTag = LoggerTag("MXOlmDecryption", LoggerTag.CRYPTO)
internal class MXOlmDecryption(
// The olm device interface
private val olmDevice: MXOlmDevice,
@@ -38,27 +41,27 @@ internal class MXOlmDecryption(
IMXDecrypting {
@Throws(MXCryptoError::class)
- override fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
+ override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
val olmEventContent = event.content.toModel() ?: run {
- Timber.e("## decryptEvent() : bad event format")
+ Timber.tag(loggerTag.value).e("## decryptEvent() : bad event format")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_EVENT_FORMAT,
MXCryptoError.BAD_EVENT_FORMAT_TEXT_REASON)
}
val cipherText = olmEventContent.ciphertext ?: run {
- Timber.e("## decryptEvent() : missing cipher text")
+ Timber.tag(loggerTag.value).e("## decryptEvent() : missing cipher text")
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_CIPHER_TEXT,
MXCryptoError.MISSING_CIPHER_TEXT_REASON)
}
val senderKey = olmEventContent.senderKey ?: run {
- Timber.e("## decryptEvent() : missing sender key")
+ Timber.tag(loggerTag.value).e("## decryptEvent() : missing sender key")
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_SENDER_KEY,
MXCryptoError.MISSING_SENDER_KEY_TEXT_REASON)
}
val messageAny = cipherText[olmDevice.deviceCurve25519Key] ?: run {
- Timber.e("## decryptEvent() : our device ${olmDevice.deviceCurve25519Key} is not included in recipients")
+ Timber.tag(loggerTag.value).e("## decryptEvent() : our device ${olmDevice.deviceCurve25519Key} is not included in recipients")
throw MXCryptoError.Base(MXCryptoError.ErrorType.NOT_INCLUDE_IN_RECIPIENTS, MXCryptoError.NOT_INCLUDED_IN_RECIPIENT_REASON)
}
@@ -69,7 +72,7 @@ internal class MXOlmDecryption(
val decryptedPayload = decryptMessage(message, senderKey)
if (decryptedPayload == null) {
- Timber.e("## decryptEvent() Failed to decrypt Olm event (id= ${event.eventId} from $senderKey")
+ Timber.tag(loggerTag.value).e("## decryptEvent() Failed to decrypt Olm event (id= ${event.eventId} from $senderKey")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE, MXCryptoError.BAD_ENCRYPTED_MESSAGE_REASON)
}
val payloadString = convertFromUTF8(decryptedPayload)
@@ -78,30 +81,30 @@ internal class MXOlmDecryption(
val payload = adapter.fromJson(payloadString)
if (payload == null) {
- Timber.e("## decryptEvent failed : null payload")
+ Timber.tag(loggerTag.value).e("## decryptEvent failed : null payload")
throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, MXCryptoError.MISSING_CIPHER_TEXT_REASON)
}
val olmPayloadContent = OlmPayloadContent.fromJsonString(payloadString) ?: run {
- Timber.e("## decryptEvent() : bad olmPayloadContent format")
+ Timber.tag(loggerTag.value).e("## decryptEvent() : bad olmPayloadContent format")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_DECRYPTED_FORMAT, MXCryptoError.BAD_DECRYPTED_FORMAT_TEXT_REASON)
}
if (olmPayloadContent.recipient.isNullOrBlank()) {
val reason = String.format(MXCryptoError.ERROR_MISSING_PROPERTY_REASON, "recipient")
- Timber.e("## decryptEvent() : $reason")
+ Timber.tag(loggerTag.value).e("## decryptEvent() : $reason")
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_PROPERTY, reason)
}
if (olmPayloadContent.recipient != userId) {
- Timber.e("## decryptEvent() : Event ${event.eventId}:" +
+ Timber.tag(loggerTag.value).e("## decryptEvent() : Event ${event.eventId}:" +
" Intended recipient ${olmPayloadContent.recipient} does not match our id $userId")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_RECIPIENT,
String.format(MXCryptoError.BAD_RECIPIENT_REASON, olmPayloadContent.recipient))
}
val recipientKeys = olmPayloadContent.recipientKeys ?: run {
- Timber.e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'recipient_keys'" +
+ Timber.tag(loggerTag.value).e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'recipient_keys'" +
" property; cannot prevent unknown-key attack")
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_PROPERTY,
String.format(MXCryptoError.ERROR_MISSING_PROPERTY_REASON, "recipient_keys"))
@@ -110,31 +113,34 @@ internal class MXOlmDecryption(
val ed25519 = recipientKeys["ed25519"]
if (ed25519 != olmDevice.deviceEd25519Key) {
- Timber.e("## decryptEvent() : Event ${event.eventId}: Intended recipient ed25519 key $ed25519 did not match ours")
+ Timber.tag(loggerTag.value).e("## decryptEvent() : Event ${event.eventId}: Intended recipient ed25519 key $ed25519 did not match ours")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_RECIPIENT_KEY,
MXCryptoError.BAD_RECIPIENT_KEY_REASON)
}
if (olmPayloadContent.sender.isNullOrBlank()) {
- Timber.e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'sender' property; cannot prevent unknown-key attack")
+ Timber.tag(loggerTag.value)
+ .e("## decryptEvent() : Olm event (id=${event.eventId}) contains no 'sender' property; cannot prevent unknown-key attack")
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_PROPERTY,
String.format(MXCryptoError.ERROR_MISSING_PROPERTY_REASON, "sender"))
}
if (olmPayloadContent.sender != event.senderId) {
- Timber.e("Event ${event.eventId}: original sender ${olmPayloadContent.sender} does not match reported sender ${event.senderId}")
+ Timber.tag(loggerTag.value)
+ .e("Event ${event.eventId}: sender ${olmPayloadContent.sender} does not match reported sender ${event.senderId}")
throw MXCryptoError.Base(MXCryptoError.ErrorType.FORWARDED_MESSAGE,
String.format(MXCryptoError.FORWARDED_MESSAGE_REASON, olmPayloadContent.sender))
}
if (olmPayloadContent.roomId != event.roomId) {
- Timber.e("## decryptEvent() : Event ${event.eventId}: original room ${olmPayloadContent.roomId} does not match reported room ${event.roomId}")
+ Timber.tag(loggerTag.value)
+ .e("## decryptEvent() : Event ${event.eventId}: room ${olmPayloadContent.roomId} does not match reported room ${event.roomId}")
throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_ROOM,
String.format(MXCryptoError.BAD_ROOM_REASON, olmPayloadContent.roomId))
}
val keys = olmPayloadContent.keys ?: run {
- Timber.e("## decryptEvent failed : null keys")
+ Timber.tag(loggerTag.value).e("## decryptEvent failed : null keys")
throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT,
MXCryptoError.MISSING_CIPHER_TEXT_REASON)
}
@@ -153,8 +159,8 @@ internal class MXOlmDecryption(
* @param message message object, with 'type' and 'body' fields.
* @return payload, if decrypted successfully.
*/
- private fun decryptMessage(message: JsonDict, theirDeviceIdentityKey: String): String? {
- val sessionIds = olmDevice.getSessionIds(theirDeviceIdentityKey).orEmpty()
+ private suspend fun decryptMessage(message: JsonDict, theirDeviceIdentityKey: String): String? {
+ val sessionIds = olmDevice.getSessionIds(theirDeviceIdentityKey)
val messageBody = message["body"] as? String ?: return null
val messageType = when (val typeAsVoid = message["type"]) {
@@ -166,11 +172,32 @@ internal class MXOlmDecryption(
// Try each session in turn
// decryptionErrors = {};
+
+ val isPreKey = messageType == 0
+ // we want to synchronize on prekey if not we could end up create two olm sessions
+ // Not very clear but it looks like the js-sdk for consistency
+ return if (isPreKey) {
+ olmDevice.mutex.withLock {
+ reallyDecryptMessage(sessionIds, messageBody, messageType, theirDeviceIdentityKey)
+ }
+ } else {
+ reallyDecryptMessage(sessionIds, messageBody, messageType, theirDeviceIdentityKey)
+ }
+ }
+
+ private suspend fun reallyDecryptMessage(sessionIds: List, messageBody: String, messageType: Int, theirDeviceIdentityKey: String): String? {
+ Timber.tag(loggerTag.value).d("decryptMessage() try to decrypt olm message type:$messageType from ${sessionIds.size} known sessions")
for (sessionId in sessionIds) {
- val payload = olmDevice.decryptMessage(messageBody, messageType, sessionId, theirDeviceIdentityKey)
+ val payload = try {
+ olmDevice.decryptMessage(messageBody, messageType, sessionId, theirDeviceIdentityKey)
+ } catch (throwable: Exception) {
+ // As we are trying one by one, we don't really care of the error here
+ Timber.tag(loggerTag.value).d("decryptMessage() failed with session $sessionId")
+ null
+ }
if (null != payload) {
- Timber.v("## decryptMessage() : Decrypted Olm message from $theirDeviceIdentityKey with session $sessionId")
+ Timber.tag(loggerTag.value).v("## decryptMessage() : Decrypted Olm message from $theirDeviceIdentityKey with session $sessionId")
return payload
} else {
val foundSession = olmDevice.matchesSession(theirDeviceIdentityKey, sessionId, messageType, messageBody)
@@ -178,7 +205,7 @@ internal class MXOlmDecryption(
if (foundSession) {
// Decryption failed, but it was a prekey message matching this
// session, so it should have worked.
- Timber.e("## decryptMessage() : Error decrypting prekey message with existing session id $sessionId:TODO")
+ Timber.tag(loggerTag.value).e("## decryptMessage() : Error decrypting prekey message with existing session id $sessionId:TODO")
return null
}
}
@@ -189,9 +216,9 @@ internal class MXOlmDecryption(
// didn't work.
if (sessionIds.isEmpty()) {
- Timber.e("## decryptMessage() : No existing sessions")
+ Timber.tag(loggerTag.value).e("## decryptMessage() : No existing sessions")
} else {
- Timber.e("## decryptMessage() : Error decrypting non-prekey message with existing sessions")
+ Timber.tag(loggerTag.value).e("## decryptMessage() : Error decrypting non-prekey message with existing sessions")
}
return null
@@ -199,14 +226,17 @@ internal class MXOlmDecryption(
// prekey message which doesn't match any existing sessions: make a new
// session.
+ // XXXX Possible races here? if concurrent access for same prekey message, we might create 2 sessions?
+ Timber.tag(loggerTag.value).d("## decryptMessage() : Create inbound group session from prekey sender:$theirDeviceIdentityKey")
+
val res = olmDevice.createInboundSession(theirDeviceIdentityKey, messageType, messageBody)
if (null == res) {
- Timber.e("## decryptMessage() : Error decrypting non-prekey message with existing sessions")
+ Timber.tag(loggerTag.value).e("## decryptMessage() : Error decrypting non-prekey message with existing sessions")
return null
}
- Timber.v("## decryptMessage() : Created new inbound Olm session get id ${res["session_id"]} with $theirDeviceIdentityKey")
+ Timber.tag(loggerTag.value).v("## decryptMessage() : Created new inbound Olm session get id ${res["session_id"]} with $theirDeviceIdentityKey")
return res["payload"]
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt
index 5cd647ff6f..9325355d28 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/UpdateTrustWorker.kt
@@ -96,7 +96,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses
if (userList.isNotEmpty()) {
// Unfortunately we don't have much info on what did exactly changed (is it the cross signing keys of that user,
// or a new device?) So we check all again :/
- Timber.d("## CrossSigning - Updating trust for users: ${userList.logLimit()}")
+ Timber.v("## CrossSigning - Updating trust for users: ${userList.logLimit()}")
updateTrust(userList)
}
@@ -148,7 +148,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses
myUserId -> myTrustResult
else -> {
crossSigningService.checkOtherMSKTrusted(myCrossSigningInfo, entry.value).also {
- Timber.d("## CrossSigning - user:${entry.key} result:$it")
+ Timber.v("## CrossSigning - user:${entry.key} result:$it")
}
}
}
@@ -178,7 +178,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses
// Update trust if needed
devicesEntities?.forEach { device ->
val crossSignedVerified = trustMap?.get(device)?.isCrossSignedVerified()
- Timber.d("## CrossSigning - Trust for ${device.userId}|${device.deviceId} : cross verified: ${trustMap?.get(device)}")
+ Timber.v("## CrossSigning - Trust for ${device.userId}|${device.deviceId} : cross verified: ${trustMap?.get(device)}")
if (device.trustLevelEntity?.crossSignedVerified != crossSignedVerified) {
Timber.d("## CrossSigning - Trust change detected for ${device.userId}|${device.deviceId} : cross verified: $crossSignedVerified")
// need to save
@@ -216,7 +216,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses
.equalTo(RoomSummaryEntityFields.IS_ENCRYPTED, true)
.findFirst()
?.let { roomSummary ->
- Timber.d("## CrossSigning - Check shield state for room $roomId")
+ Timber.v("## CrossSigning - Check shield state for room $roomId")
val allActiveRoomMembers = RoomMemberHelper(sessionRealm, roomId).getActiveRoomMemberIds()
try {
val updatedTrust = computeRoomShield(
@@ -277,7 +277,7 @@ internal class UpdateTrustWorker(context: Context, params: WorkerParameters, ses
cryptoRealm: Realm,
activeMemberUserIds: List,
roomSummaryEntity: RoomSummaryEntity): RoomEncryptionTrustLevel {
- Timber.d("## CrossSigning - computeRoomShield ${roomSummaryEntity.roomId} -> ${activeMemberUserIds.logLimit()}")
+ Timber.v("## CrossSigning - computeRoomShield ${roomSummaryEntity.roomId} -> ${activeMemberUserIds.logLimit()}")
// The set of “all users” depends on the type of room:
// For regular / topic rooms which have more than 2 members (including yourself) are considered when decorating a room
// For 1:1 and group DM rooms, all other users (i.e. excluding yourself) are considered when decorating a room
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt
index b20168eaa3..954c2dbe43 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt
@@ -671,7 +671,6 @@ internal class DefaultKeysBackupService @Inject constructor(
Timber.e("restoreKeysWithRecoveryKey: Invalid recovery key for this keys version")
throw InvalidParameterException("Invalid recovery key")
}
-
// Get a PK decryption instance
pkDecryptionFromRecoveryKey(recoveryKey)
}
@@ -681,6 +680,10 @@ internal class DefaultKeysBackupService @Inject constructor(
throw InvalidParameterException("Invalid recovery key")
}
+ // Save for next time and for gossiping
+ // Save now as it's valid, don't wait for the import as it could take long.
+ saveBackupRecoveryKey(recoveryKey, keysVersionResult.version)
+
stepProgressListener?.onStepProgress(StepProgressListener.Step.DownloadingKey)
// Get backed up keys from the homeserver
@@ -729,8 +732,6 @@ internal class DefaultKeysBackupService @Inject constructor(
if (backUp) {
maybeBackupKeys()
}
- // Save for next time and for gossiping
- saveBackupRecoveryKey(recoveryKey, keysVersionResult.version)
result
}
}.foldToCallback(callback)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt
index 5e7744853a..b3638dc414 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt
@@ -70,6 +70,8 @@ data class CryptoDeviceInfo(
keys?.let { map["keys"] = it }
return map
}
+
+ fun shortDebugString() = "$userId|$deviceId"
}
internal fun CryptoDeviceInfo.toRest(): DeviceKeys {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt
index 15b92f105a..263cb3b036 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmSessionWrapper.kt
@@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.crypto.model
+import kotlinx.coroutines.sync.Mutex
import org.matrix.olm.OlmSession
/**
@@ -25,7 +26,10 @@ data class OlmSessionWrapper(
// The associated olm session.
val olmSession: OlmSession,
// Timestamp at which the session last received a message.
- var lastReceivedMessageTs: Long = 0) {
+ var lastReceivedMessageTs: Long = 0,
+
+ val mutex: Mutex = Mutex()
+) {
/**
* Notify that a message has been received on this olm session so that it updates `lastReceivedMessageTs`
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt
index 96ea5c03fa..e662ff74e7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt
@@ -54,7 +54,7 @@ internal interface IMXCryptoStore {
/**
* @return the olm account
*/
- fun getOlmAccount(): OlmAccount
+ fun doWithOlmAccount(block: (OlmAccount) -> T): T
fun getOrCreateOlmAccount(): OlmAccount
@@ -261,7 +261,7 @@ internal interface IMXCryptoStore {
fun storeSession(olmSessionWrapper: OlmSessionWrapper, deviceKey: String)
/**
- * Retrieve the end-to-end session ids between the logged-in user and another
+ * Retrieve all end-to-end session ids between our own device and another
* device.
*
* @param deviceKey the public key of the other device.
@@ -270,7 +270,7 @@ internal interface IMXCryptoStore {
fun getDeviceSessionIds(deviceKey: String): List?
/**
- * Retrieve an end-to-end session between the logged-in user and another
+ * Retrieve an end-to-end session between our own device and another
* device.
*
* @param sessionId the session Id.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt
index 95a0c09c4b..14c1741a34 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt
@@ -104,7 +104,6 @@ import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.inject.Inject
-import kotlin.collections.set
@SessionScope
internal class RealmCryptoStore @Inject constructor(
@@ -124,12 +123,6 @@ internal class RealmCryptoStore @Inject constructor(
// The olm account
private var olmAccount: OlmAccount? = null
- // Cache for OlmSession, to release them properly
- private val olmSessionsToRelease = HashMap()
-
- // Cache for InboundGroupSession, to release them properly
- private val inboundGroupSessionToRelease = HashMap()
-
private val newSessionListeners = ArrayList()
override fun addNewSessionListener(listener: NewSessionListener) {
@@ -213,16 +206,6 @@ internal class RealmCryptoStore @Inject constructor(
monarchyWriteAsyncExecutor.awaitTermination(1, TimeUnit.MINUTES)
}
- olmSessionsToRelease.forEach {
- it.value.olmSession.releaseSession()
- }
- olmSessionsToRelease.clear()
-
- inboundGroupSessionToRelease.forEach {
- it.value.olmInboundGroupSession?.releaseSession()
- }
- inboundGroupSessionToRelease.clear()
-
olmAccount?.releaseAccount()
realmLocker?.close()
@@ -247,10 +230,18 @@ internal class RealmCryptoStore @Inject constructor(
}
}
- override fun getOlmAccount(): OlmAccount {
- return olmAccount!!
+ /**
+ * Olm account access should be synchronized
+ */
+ override fun doWithOlmAccount(block: (OlmAccount) -> T): T {
+ return olmAccount!!.let { olmAccount ->
+ synchronized(olmAccount) {
+ block.invoke(olmAccount)
+ }
+ }
}
+ @Synchronized
override fun getOrCreateOlmAccount(): OlmAccount {
doRealmTransaction(realmConfiguration) {
val metaData = it.where().findFirst()
@@ -682,13 +673,6 @@ internal class RealmCryptoStore @Inject constructor(
if (sessionIdentifier != null) {
val key = OlmSessionEntity.createPrimaryKey(sessionIdentifier, deviceKey)
- // Release memory of previously known session, if it is not the same one
- if (olmSessionsToRelease[key]?.olmSession != olmSessionWrapper.olmSession) {
- olmSessionsToRelease[key]?.olmSession?.releaseSession()
- }
-
- olmSessionsToRelease[key] = olmSessionWrapper
-
doRealmTransaction(realmConfiguration) {
val realmOlmSession = OlmSessionEntity().apply {
primaryKey = key
@@ -705,23 +689,18 @@ internal class RealmCryptoStore @Inject constructor(
override fun getDeviceSession(sessionId: String, deviceKey: String): OlmSessionWrapper? {
val key = OlmSessionEntity.createPrimaryKey(sessionId, deviceKey)
-
- // If not in cache (or not found), try to read it from realm
- if (olmSessionsToRelease[key] == null) {
- doRealmQueryAndCopy(realmConfiguration) {
- it.where()
- .equalTo(OlmSessionEntityFields.PRIMARY_KEY, key)
- .findFirst()
- }
- ?.let {
- val olmSession = it.getOlmSession()
- if (olmSession != null && it.sessionId != null) {
- olmSessionsToRelease[key] = OlmSessionWrapper(olmSession, it.lastReceivedMessageTs)
- }
- }
+ return doRealmQueryAndCopy(realmConfiguration) {
+ it.where()
+ .equalTo(OlmSessionEntityFields.PRIMARY_KEY, key)
+ .findFirst()
}
-
- return olmSessionsToRelease[key]
+ ?.let {
+ val olmSession = it.getOlmSession()
+ if (olmSession != null && it.sessionId != null) {
+ return@let OlmSessionWrapper(olmSession, it.lastReceivedMessageTs)
+ }
+ null
+ }
}
override fun getLastUsedSessionId(deviceKey: String): String? {
@@ -763,13 +742,6 @@ internal class RealmCryptoStore @Inject constructor(
if (sessionIdentifier != null) {
val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionIdentifier, session.senderKey)
- // Release memory of previously known session, if it is not the same one
- if (inboundGroupSessionToRelease[key] != session) {
- inboundGroupSessionToRelease[key]?.olmInboundGroupSession?.releaseSession()
- }
-
- inboundGroupSessionToRelease[key] = session
-
val realmOlmInboundGroupSession = OlmInboundGroupSessionEntity().apply {
primaryKey = key
sessionId = sessionIdentifier
@@ -786,20 +758,12 @@ internal class RealmCryptoStore @Inject constructor(
override fun getInboundGroupSession(sessionId: String, senderKey: String): OlmInboundGroupSessionWrapper2? {
val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionId, senderKey)
- // If not in cache (or not found), try to read it from realm
- if (inboundGroupSessionToRelease[key] == null) {
- doWithRealm(realmConfiguration) {
- it.where()
- .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key)
- .findFirst()
- ?.getInboundGroupSession()
- }
- ?.let {
- inboundGroupSessionToRelease[key] = it
- }
+ return doWithRealm(realmConfiguration) {
+ it.where()
+ .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key)
+ .findFirst()
+ ?.getInboundGroupSession()
}
-
- return inboundGroupSessionToRelease[key]
}
override fun getCurrentOutboundGroupSessionForRoom(roomId: String): OutboundGroupSessionWrapper? {
@@ -855,10 +819,6 @@ internal class RealmCryptoStore @Inject constructor(
override fun removeInboundGroupSession(sessionId: String, senderKey: String) {
val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionId, senderKey)
- // Release memory of previously known session
- inboundGroupSessionToRelease[key]?.olmInboundGroupSession?.releaseSession()
- inboundGroupSessionToRelease.remove(key)
-
doRealmTransaction(realmConfiguration) {
it.where()
.equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
index 726c2f1323..43eb863ea4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
@@ -47,6 +47,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo022
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo023
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo024
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo025
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo026
import org.matrix.android.sdk.internal.util.Normalizer
import timber.log.Timber
import javax.inject.Inject
@@ -65,7 +66,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
private val scSchemaVersion = 4L
private val scSchemaVersionOffset = (1L shl 12)
- val schemaVersion = 25L +
+ val schemaVersion = 26L +
scSchemaVersion * scSchemaVersionOffset
override fun migrate(realm: DynamicRealm, combinedOldVersion: Long, newVersion: Long) {
@@ -99,6 +100,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 23) MigrateSessionTo023(realm).perform()
if (oldVersion < 24) MigrateSessionTo024(realm).perform()
if (oldVersion < 25) MigrateSessionTo025(realm).perform()
+ if (oldVersion < 26) MigrateSessionTo026(realm).perform()
if (oldScVersion <= 0) MigrateScSessionTo001(realm).perform()
if (oldScVersion <= 1) MigrateScSessionTo002(realm).perform()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
index f67da2974e..fd137c24ca 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
@@ -83,17 +83,18 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity,
internal fun ChunkEntity.addTimelineEvent(roomId: String,
eventEntity: EventEntity,
direction: PaginationDirection,
- roomMemberContentsByUser: Map? = null) {
+ ownedByThreadChunk: Boolean = false,
+ roomMemberContentsByUser: Map? = null): TimelineEventEntity? {
val eventId = eventEntity.eventId
if (timelineEvents.find(eventId) != null) {
- return
+ return null
}
val displayIndex = nextDisplayIndex(direction)
val localId = TimelineEventEntity.nextId(realm)
val senderId = eventEntity.sender ?: ""
// Update RR for the sender of a new message with a dummy one
- val readReceiptsSummaryEntity = handleReadReceipts(realm, roomId, eventEntity, senderId)
+ val readReceiptsSummaryEntity = if (!ownedByThreadChunk) handleReadReceipts(realm, roomId, eventEntity, senderId) else null
val timelineEventEntity = realm.createObject().apply {
this.localId = localId
this.root = eventEntity
@@ -103,6 +104,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String,
?.also { it.cleanUp(eventEntity.sender) }
this.readReceipts = readReceiptsSummaryEntity
this.displayIndex = displayIndex
+ this.ownedByThreadChunk = ownedByThreadChunk
val roomMemberContent = roomMemberContentsByUser?.get(senderId)
this.senderAvatar = roomMemberContent?.avatarUrl
this.senderName = roomMemberContent?.displayName
@@ -114,9 +116,10 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String,
}
// numberOfTimelineEvents++
timelineEvents.add(timelineEventEntity)
+ return timelineEventEntity
}
-private fun computeIsUnique(
+fun computeIsUnique(
realm: Realm,
roomId: String,
isLastForward: Boolean,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt
index 724f307e3b..9ad2708b43 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt
@@ -18,9 +18,16 @@ package org.matrix.android.sdk.internal.database.helper
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.RoomEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
internal fun RoomEntity.addIfNecessary(chunkEntity: ChunkEntity) {
if (!chunks.contains(chunkEntity)) {
chunks.add(chunkEntity)
}
}
+
+internal fun RoomEntity.addIfNecessary(threadSummary: ThreadSummaryEntity) {
+ if (!threadSummaries.contains(threadSummary)) {
+ threadSummaries.add(threadSummary)
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
index f703bfaf82..ee3008d40b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
@@ -34,7 +34,7 @@ import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.database.query.whereRoomId
-private typealias ThreadSummary = Pair?
+private typealias Summary = Pair?
/**
* Finds the root thread event and update it with the latest message summary along with the number
@@ -93,11 +93,12 @@ internal fun EventEntity.markEventAsRoot(
* @param rootThreadEventId The root eventId that will find the number of threads
* @return A ThreadSummary containing the counted threads and the latest event message
*/
-internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary {
+internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): Summary {
// Number of messages
val messages = TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
+ .distinct(TimelineEventEntityFields.ROOT.EVENT_ID)
.count()
.toInt()
@@ -123,7 +124,7 @@ internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId:
result ?: return null
- return ThreadSummary(messages, result)
+ return Summary(messages, result)
}
/**
@@ -156,6 +157,7 @@ internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm,
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
+ .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false)
.sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING)
/**
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt
new file mode 100644
index 0000000000..7087f07162
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt
@@ -0,0 +1,328 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.helper
+
+import io.realm.Realm
+import io.realm.RealmQuery
+import io.realm.Sort
+import io.realm.kotlin.createObject
+import org.matrix.android.sdk.api.session.crypto.CryptoService
+import org.matrix.android.sdk.api.session.crypto.MXCryptoError
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
+import org.matrix.android.sdk.api.session.room.send.SendState
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType
+import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
+import org.matrix.android.sdk.internal.database.mapper.asDomain
+import org.matrix.android.sdk.internal.database.mapper.toEntity
+import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity
+import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
+import org.matrix.android.sdk.internal.database.model.EventEntity
+import org.matrix.android.sdk.internal.database.model.EventInsertType
+import org.matrix.android.sdk.internal.database.model.RoomEntity
+import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields
+import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
+import org.matrix.android.sdk.internal.database.query.getOrCreate
+import org.matrix.android.sdk.internal.database.query.getOrNull
+import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent
+import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDecryptor
+import timber.log.Timber
+import java.util.UUID
+
+internal fun ThreadSummaryEntity.updateThreadSummary(
+ rootThreadEventEntity: EventEntity,
+ numberOfThreads: Int?,
+ latestThreadEventEntity: EventEntity?,
+ isUserParticipating: Boolean,
+ roomMemberContentsByUser: HashMap) {
+ updateThreadSummaryRootEvent(rootThreadEventEntity, roomMemberContentsByUser)
+ updateThreadSummaryLatestEvent(latestThreadEventEntity, roomMemberContentsByUser)
+ this.isUserParticipating = isUserParticipating
+ numberOfThreads?.let {
+ // Update number of threads only when there is an actual value
+ this.numberOfThreads = it
+ }
+}
+
+/**
+ * Updates the root thread event properties
+ */
+internal fun ThreadSummaryEntity.updateThreadSummaryRootEvent(
+ rootThreadEventEntity: EventEntity,
+ roomMemberContentsByUser: HashMap
+) {
+ val roomId = rootThreadEventEntity.roomId
+ val rootThreadRoomMemberContent = roomMemberContentsByUser[rootThreadEventEntity.sender ?: ""]
+ this.rootThreadEventEntity = rootThreadEventEntity
+ this.rootThreadSenderAvatar = rootThreadRoomMemberContent?.avatarUrl
+ this.rootThreadSenderName = rootThreadRoomMemberContent?.displayName
+ this.rootThreadIsUniqueDisplayName = if (rootThreadRoomMemberContent?.displayName != null) {
+ computeIsUnique(realm, roomId, false, rootThreadRoomMemberContent, roomMemberContentsByUser)
+ } else {
+ true
+ }
+}
+
+/**
+ * Updates the latest thread event properties
+ */
+internal fun ThreadSummaryEntity.updateThreadSummaryLatestEvent(
+ latestThreadEventEntity: EventEntity?,
+ roomMemberContentsByUser: HashMap
+) {
+ val roomId = latestThreadEventEntity?.roomId ?: return
+ val latestThreadRoomMemberContent = roomMemberContentsByUser[latestThreadEventEntity.sender ?: ""]
+ this.latestThreadEventEntity = latestThreadEventEntity
+ this.latestThreadSenderAvatar = latestThreadRoomMemberContent?.avatarUrl
+ this.latestThreadSenderName = latestThreadRoomMemberContent?.displayName
+ this.latestThreadIsUniqueDisplayName = if (latestThreadRoomMemberContent?.displayName != null) {
+ computeIsUnique(realm, roomId, false, latestThreadRoomMemberContent, roomMemberContentsByUser)
+ } else {
+ true
+ }
+}
+
+private fun EventEntity.toTimelineEventEntity(roomMemberContentsByUser: HashMap): TimelineEventEntity {
+ val roomId = roomId
+ val eventId = eventId
+ val localId = TimelineEventEntity.nextId(realm)
+ val senderId = sender ?: ""
+
+ val timelineEventEntity = realm.createObject().apply {
+ this.localId = localId
+ this.root = this@toTimelineEventEntity
+ this.eventId = eventId
+ this.roomId = roomId
+ this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst()
+ ?.also { it.cleanUp(sender) }
+ this.ownedByThreadChunk = true // To skip it from the original event flow
+ val roomMemberContent = roomMemberContentsByUser[senderId]
+ this.senderAvatar = roomMemberContent?.avatarUrl
+ this.senderName = roomMemberContent?.displayName
+ isUniqueDisplayName = if (roomMemberContent?.displayName != null) {
+ computeIsUnique(realm, roomId, false, roomMemberContent, roomMemberContentsByUser)
+ } else {
+ true
+ }
+ }
+ return timelineEventEntity
+}
+
+internal suspend fun ThreadSummaryEntity.Companion.createOrUpdate(
+ threadSummaryType: ThreadSummaryUpdateType,
+ realm: Realm,
+ roomId: String,
+ threadEventEntity: EventEntity? = null,
+ rootThreadEvent: Event? = null,
+ roomMemberContentsByUser: HashMap,
+ roomEntity: RoomEntity,
+ userId: String,
+ cryptoService: CryptoService? = null
+) {
+ when (threadSummaryType) {
+ ThreadSummaryUpdateType.REPLACE -> {
+ rootThreadEvent?.eventId ?: return
+ rootThreadEvent.senderId ?: return
+
+ val numberOfThreads = rootThreadEvent.unsignedData?.relations?.latestThread?.count ?: return
+
+ // Something is wrong with the server return
+ if (numberOfThreads <= 0) return
+
+ val threadSummary = ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEvent.eventId).also {
+ Timber.i("###THREADS ThreadSummaryHelper REPLACE eventId:${it.rootThreadEventId} ")
+ }
+
+ val rootThreadEventEntity = createEventEntity(roomId, rootThreadEvent, realm).also {
+ decryptIfNeeded(cryptoService, it, roomId)
+ }
+ val latestThreadEventEntity = createLatestEventEntity(roomId, rootThreadEvent, roomMemberContentsByUser, realm)?.also {
+ decryptIfNeeded(cryptoService, it, roomId)
+ }
+ val isUserParticipating = rootThreadEvent.unsignedData.relations.latestThread.isUserParticipating == true || rootThreadEvent.senderId == userId
+ roomMemberContentsByUser.addSenderState(realm, roomId, rootThreadEvent.senderId)
+ threadSummary.updateThreadSummary(
+ rootThreadEventEntity = rootThreadEventEntity,
+ numberOfThreads = numberOfThreads,
+ latestThreadEventEntity = latestThreadEventEntity,
+ isUserParticipating = isUserParticipating,
+ roomMemberContentsByUser = roomMemberContentsByUser
+ )
+
+ roomEntity.addIfNecessary(threadSummary)
+ }
+ ThreadSummaryUpdateType.ADD -> {
+ val rootThreadEventId = threadEventEntity?.rootThreadEventId ?: return
+ Timber.i("###THREADS ThreadSummaryHelper ADD for root eventId:$rootThreadEventId")
+
+ val threadSummary = ThreadSummaryEntity.getOrNull(realm, roomId, rootThreadEventId)
+ if (threadSummary != null) {
+ // ThreadSummary exists so lets add the latest event
+ Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId exists, lets update latest thread event.")
+ threadSummary.updateThreadSummaryLatestEvent(threadEventEntity, roomMemberContentsByUser)
+ threadSummary.numberOfThreads++
+ if (threadEventEntity.sender == userId) {
+ threadSummary.isUserParticipating = true
+ }
+ } else {
+ // ThreadSummary do not exists lets try to create one
+ Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId do not exists, lets try to create one")
+ threadEventEntity.findRootThreadEvent()?.let { rootThreadEventEntity ->
+ // Root thread event entity exists so lets create a new record
+ ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEventEntity.eventId).let {
+ it.updateThreadSummary(
+ rootThreadEventEntity = rootThreadEventEntity,
+ numberOfThreads = 1,
+ latestThreadEventEntity = threadEventEntity,
+ isUserParticipating = threadEventEntity.sender == userId,
+ roomMemberContentsByUser = roomMemberContentsByUser
+ )
+ roomEntity.addIfNecessary(it)
+ }
+ }
+ }
+ }
+ }
+}
+
+private suspend fun decryptIfNeeded(cryptoService: CryptoService?, eventEntity: EventEntity, roomId: String) {
+ cryptoService ?: return
+ val event = eventEntity.asDomain()
+ if (event.isEncrypted() && event.mxDecryptionResult == null && event.eventId != null) {
+ try {
+ Timber.i("###THREADS ThreadSummaryHelper request decryption for eventId:${event.eventId}")
+ // Event from sync does not have roomId, so add it to the event first
+ val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "")
+ event.mxDecryptionResult = OlmDecryptionResult(
+ payload = result.clearEvent,
+ senderKey = result.senderCurve25519Key,
+ keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) },
+ forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
+ )
+ // Save decryption result, to not decrypt every time we enter the thread list
+ eventEntity.setDecryptionResult(result)
+ } catch (e: MXCryptoError) {
+ if (e is MXCryptoError.Base) {
+ event.mCryptoError = e.errorType
+ event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription
+ }
+ }
+ }
+}
+
+/**
+ * Request decryption
+ */
+private fun requestDecryption(eventDecryptor: TimelineEventDecryptor?, event: Event?) {
+ eventDecryptor ?: return
+ event ?: return
+ if (event.isEncrypted() &&
+ event.mxDecryptionResult == null && event.eventId != null) {
+ Timber.i("###THREADS ThreadSummaryHelper request decryption for eventId:${event.eventId}")
+
+ eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(event, UUID.randomUUID().toString()))
+ }
+}
+
+/**
+ * If we don't have any new state on this user, get it from db
+ */
+private fun HashMap.addSenderState(realm: Realm, roomId: String, senderId: String) {
+ getOrPut(senderId) {
+ CurrentStateEventEntity
+ .getOrNull(realm, roomId, senderId, EventType.STATE_ROOM_MEMBER)
+ ?.root?.asDomain()
+ ?.getFixedRoomMemberContent()
+ }
+}
+
+/**
+ * Create an EventEntity for the root thread event or get an existing one
+ */
+private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity {
+ val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
+ return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
+}
+
+/**
+ * Create an EventEntity for the latest thread event or get an existing one. Also update the user room member
+ * state
+ */
+private fun createLatestEventEntity(
+ roomId: String,
+ rootThreadEvent: Event,
+ roomMemberContentsByUser: HashMap,
+ realm: Realm): EventEntity? {
+ return getLatestEvent(rootThreadEvent)?.let {
+ it.senderId?.let { senderId ->
+ roomMemberContentsByUser.addSenderState(realm, roomId, senderId)
+ }
+ createEventEntity(roomId, it, realm)
+ }
+}
+
+/**
+ * Returned the latest event message, if any
+ */
+private fun getLatestEvent(rootThreadEvent: Event): Event? {
+ return rootThreadEvent.unsignedData?.relations?.latestThread?.event
+}
+
+/**
+ * Find all ThreadSummaryEntity for the specified roomId, sorted by origin server
+ * note: Sorting cannot be provided by server, so we have to use that unstable property
+ * @param roomId The id of the room
+ */
+internal fun ThreadSummaryEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery =
+ ThreadSummaryEntity
+ .where(realm, roomId = roomId)
+ .sort(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.ORIGIN_SERVER_TS, Sort.DESCENDING)
+
+/**
+ * Enhance each [ThreadSummary] root and latest event with the equivalent decrypted text edition/replacement
+ */
+internal fun List.enhanceWithEditions(realm: Realm, roomId: String): List =
+ this.map {
+ it.addEditionIfNeeded(realm, roomId, true)
+ it.addEditionIfNeeded(realm, roomId, false)
+ it
+ }
+
+private fun ThreadSummary.addEditionIfNeeded(realm: Realm, roomId: String, enhanceRoot: Boolean) {
+ val eventId = if (enhanceRoot) rootEventId else latestEvent?.eventId ?: return
+ EventAnnotationsSummaryEntity
+ .where(realm, roomId, eventId)
+ .findFirst()
+ ?.editSummary
+ ?.editions
+ ?.lastOrNull()
+ ?.eventId
+ ?.let { editedEventId ->
+ TimelineEventEntity.where(realm, roomId, eventId = editedEventId).findFirst()?.let { editedEvent ->
+ if (enhanceRoot) {
+ threadEditions.rootThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() ?: "(edited)"
+ } else {
+ threadEditions.latestThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() ?: "(edited)"
+ }
+ }
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt
index 700b94a985..069e539e2c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt
@@ -19,15 +19,19 @@ package org.matrix.android.sdk.internal.database.lightweight
import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
+import org.matrix.android.sdk.api.MatrixConfiguration
import javax.inject.Inject
/**
* The purpose of this class is to provide an alternative and lightweight way to store settings/data
- * on the sdi without using the database. This should be used just for sdk/user preferences and
+ * on the sdk without using the database. This should be used just for sdk/user preferences and
* not for large data sets
*/
-class LightweightSettingsStorage @Inject constructor(context: Context) {
+class LightweightSettingsStorage @Inject constructor(
+ context: Context,
+ private val matrixConfiguration: MatrixConfiguration
+) {
private val sdkDefaultPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
@@ -38,7 +42,7 @@ class LightweightSettingsStorage @Inject constructor(context: Context) {
}
fun areThreadMessagesEnabled(): Boolean {
- return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, false)
+ return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, matrixConfiguration.threadMessagesEnabledDefault)
}
companion object {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt
index 9c420e81fd..c3302f5ccb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt
@@ -114,7 +114,7 @@ internal object EventMapper {
)
},
threadNotificationState = eventEntity.threadNotificationState,
- threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(),
+ threadSummaryLatestEvent = eventEntity.threadSummaryLatestMessage?.root?.asDomain(),
lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
index 7869506015..2e33988a22 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
@@ -41,7 +41,8 @@ internal object HomeServerCapabilitiesMapper {
maxUploadFileSize = entity.maxUploadFileSize,
lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported,
defaultIdentityServerUrl = entity.defaultIdentityServerUrl,
- roomVersions = mapRoomVersion(entity.roomVersionsJson)
+ roomVersions = mapRoomVersion(entity.roomVersionsJson),
+ canUseThreading = entity.canUseThreading
)
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt
new file mode 100644
index 0000000000..cedb9e3d45
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.mapper
+
+import org.matrix.android.sdk.api.session.room.sender.SenderInfo
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
+import javax.inject.Inject
+
+internal class ThreadSummaryMapper @Inject constructor() {
+
+ fun map(threadSummary: ThreadSummaryEntity): ThreadSummary {
+ return ThreadSummary(
+ roomId = threadSummary.room?.firstOrNull()?.roomId.orEmpty(),
+ rootEvent = threadSummary.rootThreadEventEntity?.asDomain(),
+ latestEvent = threadSummary.latestThreadEventEntity?.asDomain(),
+ rootEventId = threadSummary.rootThreadEventId.orEmpty(),
+ rootThreadSenderInfo = SenderInfo(
+ userId = threadSummary.rootThreadEventEntity?.sender ?: "",
+ displayName = threadSummary.rootThreadSenderName,
+ isUniqueDisplayName = threadSummary.rootThreadIsUniqueDisplayName,
+ avatarUrl = threadSummary.rootThreadSenderAvatar
+ ),
+ latestThreadSenderInfo = SenderInfo(
+ userId = threadSummary.latestThreadEventEntity?.sender ?: "",
+ displayName = threadSummary.latestThreadSenderName,
+ isUniqueDisplayName = threadSummary.latestThreadIsUniqueDisplayName,
+ avatarUrl = threadSummary.latestThreadSenderAvatar
+ ),
+ isUserParticipating = threadSummary.isUserParticipating,
+ numberOfThreads = threadSummary.numberOfThreads
+ )
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt
index 55c7f2a8ee..e658622444 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt
@@ -46,6 +46,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
avatarUrl = timelineEventEntity.senderAvatar
),
+ ownedByThreadChunk = timelineEventEntity.ownedByThreadChunk,
readReceipts = readReceipts
?.distinctBy {
it.roomMember
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt
new file mode 100644
index 0000000000..f108a91ecf
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import io.realm.FieldAttribute
+import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
+import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields
+import org.matrix.android.sdk.internal.database.model.RoomEntityFields
+import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields
+import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+/**
+ * Migrating to:
+ * Live thread list: using enhanced /messages api MSC3440
+ * Live thread timeline: using /relations api
+ */
+class MigrateSessionTo026(realm: DynamicRealm) : RealmMigrator(realm, 26) {
+
+ override fun doMigrate(realm: DynamicRealm) {
+ realm.schema.get("ChunkEntity")
+ ?.addField(ChunkEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED)
+ ?.addField(ChunkEntityFields.IS_LAST_FORWARD_THREAD, Boolean::class.java, FieldAttribute.INDEXED)
+
+ realm.schema.get("TimelineEventEntity")
+ ?.addField(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, Boolean::class.java)
+
+ val eventEntity = realm.schema.get("EventEntity") ?: return
+ val threadSummaryEntity = realm.schema.create("ThreadSummaryEntity")
+ .addField(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED)
+ .addField(ThreadSummaryEntityFields.ROOT_THREAD_SENDER_NAME, String::class.java)
+ .addField(ThreadSummaryEntityFields.ROOT_THREAD_SENDER_AVATAR, String::class.java)
+ .addField(ThreadSummaryEntityFields.ROOT_THREAD_IS_UNIQUE_DISPLAY_NAME, Boolean::class.java)
+ .addField(ThreadSummaryEntityFields.LATEST_THREAD_SENDER_NAME, String::class.java)
+ .addField(ThreadSummaryEntityFields.LATEST_THREAD_SENDER_AVATAR, String::class.java)
+ .addField(ThreadSummaryEntityFields.LATEST_THREAD_IS_UNIQUE_DISPLAY_NAME, Boolean::class.java)
+ .addField(ThreadSummaryEntityFields.NUMBER_OF_THREADS, Int::class.java)
+ .addField(ThreadSummaryEntityFields.IS_USER_PARTICIPATING, Boolean::class.java)
+ .addRealmObjectField(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ENTITY.`$`, eventEntity)
+ .addRealmObjectField(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.`$`, eventEntity)
+
+ realm.schema.get("RoomEntity")
+ ?.addRealmListField(RoomEntityFields.THREAD_SUMMARIES.`$`, threadSummaryEntity)
+
+ realm.schema.get("HomeServerCapabilitiesEntity")
+ ?.addField(HomeServerCapabilitiesEntityFields.CAN_USE_THREADING, Boolean::class.java)
+ ?.forceRefreshOfHomeServerCapabilities()
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt
index c45c27ed08..88eb821aa9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt
@@ -33,7 +33,10 @@ internal open class ChunkEntity(@Index var prevToken: String? = null,
var timelineEvents: RealmList = RealmList(),
// Only one chunk will have isLastForward == true
@Index var isLastForward: Boolean = false,
- @Index var isLastBackward: Boolean = false
+ @Index var isLastBackward: Boolean = false,
+ // Threads
+ @Index var rootThreadEventId: String? = null,
+ @Index var isLastForwardThread: Boolean = false,
) : RealmObject() {
fun identifier() = "${prevToken}_$nextToken"
@@ -47,14 +50,32 @@ internal open class ChunkEntity(@Index var prevToken: String? = null,
companion object
}
-internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRoot: Boolean) {
+internal fun ChunkEntity.deleteOnCascade(
+ deleteStateEvents: Boolean,
+ canDeleteRoot: Boolean) {
assertIsManaged()
if (deleteStateEvents) {
stateEvents.deleteAllFromRealm()
}
timelineEvents.clearWith {
val deleteRoot = canDeleteRoot && (it.root?.stateKey == null || deleteStateEvents)
+ if (deleteRoot) {
+ room?.firstOrNull()?.removeThreadSummaryIfNeeded(it.eventId)
+ }
it.deleteOnCascade(deleteRoot)
}
deleteFromRealm()
}
+
+/**
+ * Delete the chunk along with the thread events that were temporarily created
+ */
+internal fun ChunkEntity.deleteAndClearThreadEvents() {
+ assertIsManaged()
+ timelineEvents
+ .filter { it.ownedByThreadChunk }
+ .forEach {
+ it.deleteOnCascade(false)
+ }
+ deleteFromRealm()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt
index 445181e576..b7158ba9cd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt
@@ -34,14 +34,14 @@ internal open class EventEntity(@Index var eventId: String = "",
@Index var stateKey: String? = null,
var originServerTs: Long? = null,
@Index var sender: String? = null,
- // Can contain a serialized MatrixError
+ // Can contain a serialized MatrixError
var sendStateDetails: String? = null,
var age: Long? = 0,
var unsignedData: String? = null,
var redacts: String? = null,
var decryptionResultJson: String? = null,
var ageLocalTs: Long? = null,
- // Thread related, no need to create a new Entity for performance
+ // Thread related, no need to create a new Entity for performance
@Index var isRootThread: Boolean = false,
@Index var rootThreadEventId: String? = null,
var numberOfThreads: Int = 0,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
index 08ecd5995e..47a83f0ed9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
@@ -28,7 +28,8 @@ internal open class HomeServerCapabilitiesEntity(
var maxUploadFileSize: Long = HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN,
var lastVersionIdentityServerSupported: Boolean = false,
var defaultIdentityServerUrl: String? = null,
- var lastUpdatedTimestamp: Long = 0L
+ var lastUpdatedTimestamp: Long = 0L,
+ var canUseThreading: Boolean = false
) : RealmObject() {
companion object
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt
index 2997d5d7d8..4a6f6a7bf8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt
@@ -20,10 +20,14 @@ import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import org.matrix.android.sdk.api.session.room.model.Membership
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
+import org.matrix.android.sdk.internal.database.query.findRootOrLatest
+import org.matrix.android.sdk.internal.extensions.assertIsManaged
internal open class RoomEntity(@PrimaryKey var roomId: String = "",
var chunks: RealmList = RealmList(),
var sendingTimelineEvents: RealmList = RealmList(),
+ var threadSummaries: RealmList = RealmList(),
var accountData: RealmList = RealmList()
) : RealmObject() {
@@ -46,3 +50,10 @@ internal open class RoomEntity(@PrimaryKey var roomId: String = "",
}
companion object
}
+internal fun RoomEntity.removeThreadSummaryIfNeeded(eventId: String) {
+ assertIsManaged()
+ threadSummaries.findRootOrLatest(eventId)?.let {
+ threadSummaries.remove(it)
+ it.deleteFromRealm()
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
index c090777972..d0d23dd491 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
@@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.database.model
import io.realm.annotations.RealmModule
import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
/**
* Realm module for Session
@@ -66,6 +67,7 @@ import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntit
RoomAccountDataEntity::class,
SpaceChildSummaryEntity::class,
SpaceParentSummaryEntity::class,
- UserPresenceEntity::class
+ UserPresenceEntity::class,
+ ThreadSummaryEntity::class
])
internal class SessionRealmModule
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
index 185f0e2dcc..aacd6570bc 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
@@ -32,6 +32,9 @@ internal open class TimelineEventEntity(var localId: Long = 0,
var isUniqueDisplayName: Boolean = false,
var senderAvatar: String? = null,
var senderMembershipEventId: String? = null,
+ // ownedByThreadChunk indicates that the current TimelineEventEntity belongs
+ // to a thread chunk and is a temporarily event.
+ var ownedByThreadChunk: Boolean = false,
var readReceipts: ReadReceiptsSummaryEntity? = null
) : RealmObject() {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt
new file mode 100644
index 0000000000..ab9d66548e
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.model.threads
+
+import io.realm.RealmObject
+import io.realm.RealmResults
+import io.realm.annotations.Index
+import io.realm.annotations.LinkingObjects
+import org.matrix.android.sdk.internal.database.model.EventEntity
+import org.matrix.android.sdk.internal.database.model.RoomEntity
+
+internal open class ThreadSummaryEntity(@Index var rootThreadEventId: String? = "",
+ var rootThreadEventEntity: EventEntity? = null,
+ var latestThreadEventEntity: EventEntity? = null,
+ var rootThreadSenderName: String? = null,
+ var latestThreadSenderName: String? = null,
+ var rootThreadSenderAvatar: String? = null,
+ var latestThreadSenderAvatar: String? = null,
+ var rootThreadIsUniqueDisplayName: Boolean = false,
+ var isUserParticipating: Boolean = false,
+ var latestThreadIsUniqueDisplayName: Boolean = false,
+ var numberOfThreads: Int = 0
+) : RealmObject() {
+
+ @LinkingObjects("threadSummaries")
+ val room: RealmResults? = null
+
+ companion object
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt
index 109bda010e..a33ba82f7a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt
@@ -56,10 +56,22 @@ internal fun ChunkEntity.Companion.findLastForwardChunkOfRoom(realm: Realm, room
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findFirst()
}
-
+internal fun ChunkEntity.Companion.findLastForwardChunkOfThread(realm: Realm, roomId: String, rootThreadEventId: String): ChunkEntity? {
+ return where(realm, roomId)
+ .equalTo(ChunkEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
+ .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true)
+ .findFirst()
+}
+internal fun ChunkEntity.Companion.findEventInThreadChunk(realm: Realm, roomId: String, event: String): ChunkEntity? {
+ return where(realm, roomId)
+ .`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, arrayListOf(event).toTypedArray())
+ .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true)
+ .findFirst()
+}
internal fun ChunkEntity.Companion.findAllIncludingEvents(realm: Realm, eventIds: List): RealmResults {
return realm.where()
.`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, eventIds.toTypedArray())
+ .isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID)
.findAll()
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt
index 14cb7e22da..6caa832110 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt
@@ -34,7 +34,7 @@ internal fun EventAnnotationsSummaryEntity.Companion.create(realm: Realm, roomId
this.roomId = roomId
}
// Denormalization
- TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findFirst()?.let {
+ TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findAll()?.forEach {
it.annotations = obj
}
return obj
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt
new file mode 100644
index 0000000000..517d43d7cf
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.query
+
+import io.realm.Realm
+import io.realm.RealmList
+import io.realm.RealmQuery
+import io.realm.kotlin.createObject
+import io.realm.kotlin.where
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields
+
+internal fun ThreadSummaryEntity.Companion.where(realm: Realm, roomId: String): RealmQuery {
+ return realm.where()
+ .equalTo(ThreadSummaryEntityFields.ROOM.ROOM_ID, roomId)
+}
+
+internal fun ThreadSummaryEntity.Companion.where(realm: Realm, roomId: String, rootThreadEventId: String): RealmQuery {
+ return where(realm, roomId)
+ .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
+}
+
+internal fun ThreadSummaryEntity.Companion.getOrCreate(realm: Realm, roomId: String, rootThreadEventId: String): ThreadSummaryEntity {
+ return where(realm, roomId, rootThreadEventId).findFirst() ?: realm.createObject().apply {
+ this.rootThreadEventId = rootThreadEventId
+ }
+}
+internal fun ThreadSummaryEntity.Companion.getOrNull(realm: Realm, roomId: String, rootThreadEventId: String): ThreadSummaryEntity? {
+ return where(realm, roomId, rootThreadEventId).findFirst()
+}
+internal fun RealmList.find(rootThreadEventId: String): ThreadSummaryEntity? {
+ return this.where()
+ .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
+ .findFirst()
+}
+
+internal fun RealmList.findRootOrLatest(eventId: String): ThreadSummaryEntity? {
+ return this.where()
+ .beginGroup()
+ .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, eventId)
+ .or()
+ .equalTo(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.EVENT_ID, eventId)
+ .endGroup()
+ .findFirst()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt
index 63f41ebf2c..81d5ac835f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt
@@ -97,6 +97,7 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent
if (filters.filterEdits) {
not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.EDIT)
not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.RESPONSE)
+ not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.REFERENCE)
}
if (filters.filterRedacted) {
not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt
index 10a0d1dcec..a7317506a0 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt
@@ -26,6 +26,7 @@ internal object TimelineEventFilter {
internal object Content {
internal const val EDIT = """{*"m.relates_to"*"rel_type":*"m.replace"*}"""
internal const val RESPONSE = """{*"m.relates_to"*"rel_type":*"org.matrix.response"*}"""
+ internal const val REFERENCE = """{*"m.relates_to"*"rel_type":*"m.reference"*}"""
}
/**
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt
index 7d004bc5c0..fedd7d05f9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/WorkManagerProvider.kt
@@ -80,8 +80,8 @@ internal class WorkManagerProvider @Inject constructor(
workManager.enqueue(checkWorkerRequest)
val checkWorkerLiveState = workManager.getWorkInfoByIdLiveData(checkWorkerRequest.id)
val observer = object : Observer {
- override fun onChanged(workInfo: WorkInfo) {
- if (workInfo.state.isFinished) {
+ override fun onChanged(workInfo: WorkInfo?) {
+ if (workInfo?.state?.isFinished == true) {
checkWorkerLiveState.removeObserver(this)
if (workInfo.state == WorkInfo.State.FAILED) {
throw RuntimeException("MatrixWorkerFactory is not being set on your worker configuration.\n" +
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java
index 2820b66886..62f90f563e 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/riot/LoginStorage.java
@@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.legacy.riot;
+import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
@@ -196,6 +197,7 @@ public class LoginStorage {
/**
* Clear the stored values
*/
+ @SuppressLint("ApplySharedPref")
public void clear() {
SharedPreferences prefs = mContext.getSharedPreferences(PREFS_LOGIN, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
index 1ab1042129..5aec7db66c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
@@ -21,6 +21,7 @@ internal object NetworkConstants {
private const val URI_API_PREFIX_PATH = "_matrix/client"
const val URI_API_PREFIX_PATH_ = "$URI_API_PREFIX_PATH/"
const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/"
+ const val URI_API_PREFIX_PATH_V1 = "$URI_API_PREFIX_PATH/v1/"
const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/"
// Media
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
index 7415b988a4..676a4f6a38 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
@@ -17,9 +17,21 @@
package org.matrix.android.sdk.internal.session.filter
import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.events.model.RelationType
+import timber.log.Timber
internal object FilterFactory {
+ fun createThreadsFilter(numberOfEvents: Int, userId: String?): RoomEventFilter {
+ Timber.i("$userId")
+ return RoomEventFilter(
+ limit = numberOfEvents,
+// senders = listOf(userId),
+// relationSenders = userId?.let { listOf(it) },
+ relationTypes = listOf(RelationType.THREAD)
+ )
+ }
+
fun createUploadsFilter(numberOfEvents: Int): RoomEventFilter {
return RoomEventFilter(
limit = numberOfEvents,
@@ -58,8 +70,8 @@ internal object FilterFactory {
private fun createElementTimelineFilter(): RoomEventFilter? {
return null // RoomEventFilter().apply {
- // TODO Enable this for optimization
- // types = listOfSupportedEventTypes.toMutableList()
+ // TODO Enable this for optimization
+ // types = listOfSupportedEventTypes.toMutableList()
// }
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt
index f498322967..634ea73480 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt
@@ -52,12 +52,13 @@ data class RoomEventFilter(
* A list of relation types which must be exist pointing to the event being filtered.
* If this list is absent then no filtering is done on relation types.
*/
- @Json(name = "relation_types") val relationTypes: List? = null,
+ @Json(name = "related_by_rel_types") val relationTypes: List? = null,
/**
* A list of senders of relations which must exist pointing to the event being filtered.
* If this list is absent then no filtering is done on relation types.
*/
- @Json(name = "relation_senders") val relationSenders: List? = null,
+ @Json(name = "related_by_senders") val relationSenders: List? = null,
+
/**
* A list of room IDs to include. If this list is absent then all rooms are included.
*/
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt
index 830a58cd12..55526b41db 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt
@@ -65,7 +65,13 @@ internal data class Capabilities(
* Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms.
*/
@Json(name = "m.room_versions")
- val roomVersions: RoomVersions? = null
+ val roomVersions: RoomVersions? = null,
+ /**
+ * Capability to indicate if the server supports MSC3440 Threading
+ * True if the user can use m.thread relation, false otherwise
+ */
+ @Json(name = "m.thread")
+ val threads: BooleanCapability? = null
)
@JsonClass(generateAdapter = true)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
index e822cbdcdb..44e13d971a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
@@ -20,9 +20,11 @@ import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
+import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.internal.auth.version.Versions
+import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads
import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk
import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity
import org.matrix.android.sdk.internal.database.query.getOrCreate
@@ -121,6 +123,8 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
homeServerCapabilitiesEntity.roomVersionsJson = capabilities?.roomVersions?.let {
MoshiProvider.providesMoshi().adapter(RoomVersions::class.java).toJson(it)
}
+ homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */
+ getVersionResult?.doesServerSupportThreads().orFalse()
}
if (getMediaConfigResult != null) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt
index 8b05d2ea62..8ae203c2b3 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt
@@ -56,7 +56,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
val allEvents = (newJoinEvents + inviteEvents).filter { event ->
when (event.type) {
- EventType.POLL_START,
+ in EventType.POLL_START,
EventType.MESSAGE,
EventType.REDACTION,
EventType.ENCRYPTED,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt
index 144ebb5404..196a8c122d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/DefaultPermalinkService.kt
@@ -43,4 +43,8 @@ internal class DefaultPermalinkService @Inject constructor(
override fun getLinkedId(url: String): String? {
return permalinkFactory.getLinkedId(url)
}
+
+ override fun createMentionSpanTemplate(type: PermalinkService.SpanTemplateType, forceMatrixTo: Boolean): String {
+ return permalinkFactory.createMentionSpanTemplate(type, forceMatrixTo)
+ }
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt
index 39c1ddfdce..0aeb0467de 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/permalinks/PermalinkFactory.kt
@@ -21,7 +21,10 @@ import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.permalinks.PermalinkParser
+import org.matrix.android.sdk.api.session.permalinks.PermalinkService
import org.matrix.android.sdk.api.session.permalinks.PermalinkService.Companion.MATRIX_TO_URL_BASE
+import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.HTML
+import org.matrix.android.sdk.api.session.permalinks.PermalinkService.SpanTemplateType.MARKDOWN
import org.matrix.android.sdk.internal.di.UserId
import javax.inject.Inject
@@ -105,6 +108,23 @@ internal class PermalinkFactory @Inject constructor(
?.substringBeforeLast("?")
}
+ fun createMentionSpanTemplate(type: PermalinkService.SpanTemplateType, forceMatrixTo: Boolean): String {
+ return buildString {
+ when (type) {
+ HTML -> append(MENTION_SPAN_TO_HTML_TEMPLATE_BEGIN)
+ MARKDOWN -> append(MENTION_SPAN_TO_MD_TEMPLATE_BEGIN)
+ }
+ append(baseUrl(forceMatrixTo))
+ if (useClientFormat(forceMatrixTo)) {
+ append(USER_PATH)
+ }
+ when (type) {
+ HTML -> append(MENTION_SPAN_TO_HTML_TEMPLATE_END)
+ MARKDOWN -> append(MENTION_SPAN_TO_MD_TEMPLATE_END)
+ }
+ }
+ }
+
/**
* Escape '/' in id, because it is used as a separator
*
@@ -147,5 +167,9 @@ internal class PermalinkFactory @Inject constructor(
private const val ROOM_PATH = "room/"
private const val USER_PATH = "user/"
private const val GROUP_PATH = "group/"
+ private const val MENTION_SPAN_TO_HTML_TEMPLATE_BEGIN = "%2\$s"
+ private const val MENTION_SPAN_TO_MD_TEMPLATE_BEGIN = "[%2\$s]("
+ private const val MENTION_SPAN_TO_MD_TEMPLATE_END = "%1\$s)"
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt
index 2d8c3e9c78..34e859e509 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt
@@ -36,6 +36,7 @@ import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService
import org.matrix.android.sdk.api.session.room.threads.ThreadsService
+import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService
import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@@ -56,6 +57,7 @@ internal class DefaultRoom(override val roomId: String,
private val roomSummaryDataSource: RoomSummaryDataSource,
private val timelineService: TimelineService,
private val threadsService: ThreadsService,
+ private val threadsLocalService: ThreadsLocalService,
private val sendService: SendService,
private val draftService: DraftService,
private val stateService: StateService,
@@ -80,6 +82,7 @@ internal class DefaultRoom(override val roomId: String,
Room,
TimelineService by timelineService,
ThreadsService by threadsService,
+ ThreadsLocalService by threadsLocalService,
SendService by sendService,
DraftService by draftService,
StateService by stateService,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt
index 4a02c55db0..1bc98015aa 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt
@@ -109,6 +109,10 @@ internal class DefaultRoomService @Inject constructor(
return roomSummaryDataSource.getUpdatablePagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder)
}
+ override fun getRoomCountLive(queryParams: RoomSummaryQueryParams): LiveData {
+ return roomSummaryDataSource.getCountLive(queryParams)
+ }
+
override fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount {
return roomSummaryDataSource.getNotificationCountForRooms(queryParams)
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
index 1e0eb8b497..4a43cfc22a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
@@ -57,6 +57,7 @@ import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryE
import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.ReferencesAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
+import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.create
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.where
@@ -86,11 +87,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
// TODO Add ?
// EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_KEY,
- EventType.ENCRYPTED,
- EventType.POLL_START,
- EventType.POLL_RESPONSE,
- EventType.POLL_END
- )
+ EventType.ENCRYPTED
+ ) + EventType.POLL_START + EventType.POLL_RESPONSE + EventType.POLL_END
override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean {
return allowedTypes.contains(eventType)
@@ -117,8 +115,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst()
?.let {
- TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findFirst()
- ?.let { tet -> tet.annotations = it }
+ TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll()
+ ?.forEach { tet -> tet.annotations = it }
}
}
@@ -156,7 +154,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
- } else if (event.getClearType() == EventType.POLL_RESPONSE) {
+ } else if (event.getClearType() in EventType.POLL_RESPONSE) {
event.getClearContent().toModel(catchError = true)?.let { pollResponseContent ->
Timber.v("###RESPONSE in room $roomId for event ${event.eventId}")
handleResponse(realm, event, pollResponseContent, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
@@ -177,12 +175,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
handleVerification(realm, event, roomId, isLocalEcho, it)
}
}
- EventType.POLL_RESPONSE -> {
+ in EventType.POLL_RESPONSE -> {
event.getClearContent().toModel(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho, event.getRelationContent()?.eventId)
}
}
- EventType.POLL_END -> {
+ in EventType.POLL_END -> {
event.content.toModel(catchError = true)?.let {
handleEndPoll(realm, event, it, roomId, isLocalEcho)
}
@@ -196,6 +194,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
handleReaction(realm, event, roomId, isLocalEcho)
}
}
+ // HandleInitialAggregatedRelations should also be applied in encrypted messages with annotations
+// else if (event.unsignedData?.relations?.annotations != null) {
+// Timber.v("###REACTION e2e Aggregation in room $roomId for event ${event.eventId}")
+// handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations)
+// EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst()
+// ?.let {
+// TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll()
+// ?.forEach { tet -> tet.annotations = it }
+// }
+// }
}
EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
@@ -217,7 +225,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
}
}
- EventType.POLL_START -> {
+ in EventType.POLL_START -> {
val content: MessagePollContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
@@ -225,12 +233,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
handleReplace(realm, event, content, roomId, isLocalEcho)
}
}
- EventType.POLL_RESPONSE -> {
+ in EventType.POLL_RESPONSE -> {
event.content.toModel(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho)
}
}
- EventType.POLL_END -> {
+ in EventType.POLL_END -> {
event.content.toModel(catchError = true)?.let {
handleEndPoll(realm, event, it, roomId, isLocalEcho)
}
@@ -243,7 +251,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
// OPT OUT serer aggregation until API mature enough
- private val SHOULD_HANDLE_SERVER_AGREGGATION = false
+ private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e
private fun handleReplace(realm: Realm,
event: Event,
@@ -335,13 +343,18 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
if (!isLocalEcho) {
- val replaceEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst()
+ val replaceEvent = TimelineEventEntity
+ .where(realm, roomId, eventId)
+ .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false)
+ .findFirst()
handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions)
}
}
/**
* Check if the edition is on the latest thread event, and update it accordingly
+ * @param editedEvent The event that will be changed
+ * @param replaceEvent The new event
*/
private fun handleThreadSummaryEdition(editedEvent: EventEntity?,
replaceEvent: TimelineEventEntity?,
@@ -407,12 +420,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
return
}
- val option = content.response?.answers?.first() ?: return Unit.also {
+ val option = content.getBestResponse()?.answers?.first() ?: return Unit.also {
Timber.d("## POLL Ignoring malformed response no option eventId:$eventId content: ${event.content}")
}
// Check if this option is in available options
- if (!targetPollContent.pollCreationInfo?.answers?.map { it.id }?.contains(option).orFalse()) {
+ if (!targetPollContent.getBestPollCreationInfo()?.answers?.map { it.id }?.contains(option).orFalse()) {
Timber.v("## POLL $targetEventId doesn't contain option $option")
return
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
index 399bfbd0e4..10f75473b7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
@@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomStrippedState
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams
@@ -86,7 +87,7 @@ internal interface RoomAPI {
suspend fun getRoomMessagesFrom(@Path("roomId") roomId: String,
@Query("from") from: String,
@Query("dir") dir: String,
- @Query("limit") limit: Int,
+ @Query("limit") limit: Int?,
@Query("filter") filter: String?
): PaginationResponse
@@ -218,7 +219,6 @@ internal interface RoomAPI {
/**
* Paginate relations for event based in normal topological order
- *
* @param relationType filter for this relation type
* @param eventType filter for this event type
*/
@@ -227,9 +227,24 @@ internal interface RoomAPI {
@Path("eventId") eventId: String,
@Path("relationType") relationType: String,
@Path("eventType") eventType: String,
+ @Query("from") from: String? = null,
+ @Query("to") to: String? = null,
@Query("limit") limit: Int? = null
): RelationsResponse
+ /**
+ * Paginate relations for thread events based in normal topological order
+ * @param relationType filter for this relation type
+ */
+ @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "rooms/{roomId}/relations/{eventId}/{relationType}")
+ suspend fun getThreadsRelations(@Path("roomId") roomId: String,
+ @Path("eventId") eventId: String,
+ @Path("relationType") relationType: String = RelationType.THREAD,
+ @Query("from") from: String? = null,
+ @Query("to") to: String? = null,
+ @Query("limit") limit: Int? = null
+ ): RelationsResponse
+
/**
* Join the given room.
*
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt
index 70c1ab4f42..72a3f9ab22 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt
@@ -36,6 +36,7 @@ import org.matrix.android.sdk.internal.session.room.state.SendStateTask
import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource
import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService
import org.matrix.android.sdk.internal.session.room.threads.DefaultThreadsService
+import org.matrix.android.sdk.internal.session.room.threads.local.DefaultThreadsLocalService
import org.matrix.android.sdk.internal.session.room.timeline.DefaultTimelineService
import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService
import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService
@@ -52,6 +53,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService:
private val roomSummaryDataSource: RoomSummaryDataSource,
private val timelineServiceFactory: DefaultTimelineService.Factory,
private val threadsServiceFactory: DefaultThreadsService.Factory,
+ private val threadsLocalServiceFactory: DefaultThreadsLocalService.Factory,
private val sendServiceFactory: DefaultSendService.Factory,
private val draftServiceFactory: DefaultDraftService.Factory,
private val stateServiceFactory: DefaultStateService.Factory,
@@ -79,6 +81,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService:
roomSummaryDataSource = roomSummaryDataSource,
timelineService = timelineServiceFactory.create(roomId),
threadsService = threadsServiceFactory.create(roomId),
+ threadsLocalService = threadsLocalServiceFactory.create(roomId),
sendService = sendServiceFactory.create(roomId),
draftService = draftServiceFactory.create(roomId),
stateService = stateServiceFactory.create(roomId),
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt
index 030409cb0c..cfcde9c25d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt
@@ -79,7 +79,9 @@ import org.matrix.android.sdk.internal.session.room.relation.DefaultUpdateQuickR
import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask
import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask
import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask
+import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadSummariesTask
import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask
+import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask
import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask
import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask
@@ -299,4 +301,7 @@ internal abstract class RoomModule {
@Binds
abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask
+
+ @Binds
+ abstract fun bindFetchThreadSummariesTask(task: DefaultFetchThreadSummariesTask): FetchThreadSummariesTask
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
index ee52fe574b..4753e12157 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
@@ -71,7 +71,7 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
when (typeToPrune) {
EventType.ENCRYPTED,
EventType.MESSAGE,
- EventType.POLL_START -> {
+ in EventType.POLL_START -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
val unsignedData = EventMapper.map(eventToPrune).unsignedData
?: UnsignedData(null, null)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt
index d5019aea7b..ab514d31c8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt
@@ -34,7 +34,6 @@ import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
-import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDataSource
@@ -48,7 +47,6 @@ internal class DefaultRelationService @AssistedInject constructor(
private val eventFactory: LocalEchoEventFactory,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val fetchEditHistoryTask: FetchEditHistoryTask,
- private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val timelineEventDataSource: TimelineEventDataSource,
@SessionDatabase private val monarchy: Monarchy
) : RelationService {
@@ -196,10 +194,6 @@ internal class DefaultRelationService @AssistedInject constructor(
return eventSenderProcessor.postEvent(event)
}
- override suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean {
- return fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(roomId, rootThreadEventId))
- }
-
/**
* Saves the event in database as a local echo.
* SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt
new file mode 100644
index 0000000000..b596f2288e
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.room.relation.threads
+
+import com.zhuinden.monarchy.Monarchy
+import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType
+import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
+import org.matrix.android.sdk.internal.database.helper.createOrUpdate
+import org.matrix.android.sdk.internal.database.model.RoomEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
+import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.di.SessionDatabase
+import org.matrix.android.sdk.internal.di.UserId
+import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
+import org.matrix.android.sdk.internal.network.executeRequest
+import org.matrix.android.sdk.internal.session.filter.FilterFactory
+import org.matrix.android.sdk.internal.session.room.RoomAPI
+import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
+import org.matrix.android.sdk.internal.session.room.timeline.PaginationResponse
+import org.matrix.android.sdk.internal.task.Task
+import org.matrix.android.sdk.internal.util.awaitTransaction
+import timber.log.Timber
+import javax.inject.Inject
+
+/***
+ * This class is responsible to Fetch all the thread in the current room,
+ * To fetch all threads in a room, the /messages API is used with newly added filtering options.
+ */
+internal interface FetchThreadSummariesTask : Task {
+ data class Params(
+ val roomId: String,
+ val from: String = "",
+ val limit: Int = 500,
+ val isUserParticipating: Boolean = true
+ )
+}
+
+internal class DefaultFetchThreadSummariesTask @Inject constructor(
+ private val roomAPI: RoomAPI,
+ private val globalErrorReceiver: GlobalErrorReceiver,
+ @SessionDatabase private val monarchy: Monarchy,
+ private val cryptoService: DefaultCryptoService,
+ @UserId private val userId: String,
+) : FetchThreadSummariesTask {
+
+ override suspend fun execute(params: FetchThreadSummariesTask.Params): Result {
+ val filter = FilterFactory.createThreadsFilter(
+ numberOfEvents = params.limit,
+ userId = if (params.isUserParticipating) userId else null).toJSONString()
+
+ val response = executeRequest(
+ globalErrorReceiver,
+ canRetry = true
+ ) {
+ roomAPI.getRoomMessagesFrom(params.roomId, params.from, PaginationDirection.BACKWARDS.value, params.limit, filter)
+ }
+
+ Timber.i("###THREADS DefaultFetchThreadSummariesTask Fetched size:${response.events.size} nextBatch:${response.end} ")
+
+ return handleResponse(response, params)
+ }
+
+ private suspend fun handleResponse(response: PaginationResponse,
+ params: FetchThreadSummariesTask.Params): Result {
+ val rootThreadList = response.events
+ monarchy.awaitTransaction { realm ->
+ val roomEntity = RoomEntity.where(realm, roomId = params.roomId).findFirst() ?: return@awaitTransaction
+
+ val roomMemberContentsByUser = HashMap()
+ for (rootThreadEvent in rootThreadList) {
+ if (rootThreadEvent.eventId == null || rootThreadEvent.senderId == null || rootThreadEvent.type == null) {
+ continue
+ }
+
+ ThreadSummaryEntity.createOrUpdate(
+ threadSummaryType = ThreadSummaryUpdateType.REPLACE,
+ realm = realm,
+ roomId = params.roomId,
+ rootThreadEvent = rootThreadEvent,
+ roomMemberContentsByUser = roomMemberContentsByUser,
+ roomEntity = roomEntity,
+ userId = userId,
+ cryptoService = cryptoService)
+ }
+ }
+ return Result.SUCCESS
+ }
+
+ enum class Result {
+ SHOULD_FETCH_MORE,
+ REACHED_END,
+ SUCCESS
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
index e0d501c515..a46bbe8d9f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 The Matrix.org Foundation C.I.C.
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,14 +20,12 @@ import io.realm.Realm
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
-import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider
import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
-import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity
@@ -36,8 +34,10 @@ import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEnt
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntity
+import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
-import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
+import org.matrix.android.sdk.internal.database.query.find
+import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.database.query.where
@@ -47,16 +47,38 @@ import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent
import org.matrix.android.sdk.internal.session.room.RoomAPI
+import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import timber.log.Timber
import javax.inject.Inject
-internal interface FetchThreadTimelineTask : Task {
+/***
+ * This class is responsible to Fetch paginated chunks of the thread timeline using the /relations API
+ *
+ * How it works
+ *
+ * The problem?
+ * - We cannot use the existing timeline architecture to paginate through the timeline
+ * - We want our new events to be live, so any interactions with them like reactions will continue to work. We should
+ * handle appropriately the existing events from /messages api with the new events from /relations.
+ * - Handling edge cases like receiving an event from /messages while you have already created a new one from the /relations response
+ *
+ * The solution
+ * We generate a temporarily thread chunk that will be used to store any new paginated results from the /relations api
+ * We bind the timeline events from that chunk with the already existing ones. So we will have one common instance, and
+ * all reactions, edits etc will continue to work. If the events do not exists we create them
+ * and we will reuse the same EventEntity instance when (and if) the same event will be fetched from the main (/messages) timeline
+ *
+ */
+internal interface FetchThreadTimelineTask : Task {
data class Params(
val roomId: String,
- val rootThreadEventId: String
+ val rootThreadEventId: String,
+ val from: String?,
+ val limit: Int
+
)
}
@@ -69,94 +91,130 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor(
private val cryptoService: DefaultCryptoService
) : FetchThreadTimelineTask {
- override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean {
- val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId)
+ enum class Result {
+ SHOULD_FETCH_MORE,
+ REACHED_END,
+ SUCCESS
+ }
+
+ override suspend fun execute(params: FetchThreadTimelineTask.Params): Result {
val response = executeRequest(globalErrorReceiver) {
- roomAPI.getRelations(
+ roomAPI.getThreadsRelations(
roomId = params.roomId,
eventId = params.rootThreadEventId,
- relationType = RelationType.IO_THREAD,
- eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE,
- limit = 2000
+ from = params.from,
+ limit = params.limit
)
}
- val threadList = response.chunks + listOfNotNull(response.originalEvent)
+ Timber.i("###THREADS FetchThreadTimelineTask Fetched size:${response.chunks.size} nextBatch:${response.nextBatch} ")
+ return handleRelationsResponse(response, params)
+ }
- return storeNewEventsIfNeeded(threadList, params.roomId)
+ private suspend fun handleRelationsResponse(response: RelationsResponse,
+ params: FetchThreadTimelineTask.Params): Result {
+ val threadList = response.chunks
+ val threadRootEvent = response.originalEvent
+ val hasReachEnd = response.nextBatch == null
+
+ monarchy.awaitTransaction { realm ->
+
+ val threadChunk = ChunkEntity.findLastForwardChunkOfThread(realm, params.roomId, params.rootThreadEventId)
+ ?: run {
+ return@awaitTransaction
+ }
+
+ threadChunk.prevToken = response.nextBatch
+ val roomMemberContentsByUser = HashMap()
+
+ for (event in threadList) {
+ if (event.eventId == null || event.senderId == null || event.type == null) {
+ continue
+ }
+
+ if (threadChunk.timelineEvents.find(event.eventId) != null) {
+ // Event already exists in thread chunk, skip it
+ Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} already exists in thread chunk, skip it")
+ continue
+ }
+
+ val timelineEvent = TimelineEventEntity
+ .where(realm, roomId = params.roomId, event.eventId)
+ .findFirst()
+
+ if (timelineEvent != null) {
+ // Event already exists but not in the thread chunk
+ // Lets added there
+ Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} exists but not in the thread chunk, add it at the end")
+ threadChunk.timelineEvents.add(timelineEvent)
+ } else {
+ Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} is brand NEW create an entity and add it!")
+ val eventEntity = createEventEntity(params.roomId, event, realm)
+ roomMemberContentsByUser.addSenderState(realm, params.roomId, event.senderId)
+ threadChunk.addTimelineEvent(
+ roomId = params.roomId,
+ eventEntity = eventEntity,
+ direction = PaginationDirection.FORWARDS,
+ ownedByThreadChunk = true,
+ roomMemberContentsByUser = roomMemberContentsByUser)
+ }
+ }
+
+ if (hasReachEnd) {
+ val rootThread = TimelineEventEntity
+ .where(realm, roomId = params.roomId, params.rootThreadEventId)
+ .findFirst()
+ if (rootThread != null) {
+ // If root thread event already exists add it to our chunk
+ threadChunk.timelineEvents.add(rootThread)
+ Timber.i("###THREADS FetchThreadTimelineTask root thread event: ${params.rootThreadEventId} found and added!")
+ } else if (threadRootEvent?.senderId != null) {
+ // Case when thread event is not in the device
+ Timber.i("###THREADS FetchThreadTimelineTask root thread event: ${params.rootThreadEventId} NOT FOUND! Lets create a temp one")
+ val eventEntity = createEventEntity(params.roomId, threadRootEvent, realm)
+ roomMemberContentsByUser.addSenderState(realm, params.roomId, threadRootEvent.senderId)
+ threadChunk.addTimelineEvent(
+ roomId = params.roomId,
+ eventEntity = eventEntity,
+ direction = PaginationDirection.FORWARDS,
+ ownedByThreadChunk = true,
+ roomMemberContentsByUser = roomMemberContentsByUser)
+ }
+ }
+ }
+
+ return if (hasReachEnd) {
+ Result.REACHED_END
+ } else {
+ Result.SHOULD_FETCH_MORE
+ }
+ }
+
+ // TODO Reuse this function to all the app
+ /**
+ * If we don't have any new state on this user, get it from db
+ */
+ private fun HashMap.addSenderState(realm: Realm, roomId: String, senderId: String) {
+ getOrPut(senderId) {
+ CurrentStateEventEntity
+ .getOrNull(realm, roomId, senderId, EventType.STATE_ROOM_MEMBER)
+ ?.root?.asDomain()
+ ?.getFixedRoomMemberContent()
+ }
}
/**
- * Store new events if they are not already received, and returns weather or not,
- * a timeline update should be made
- * @param threadList is the list containing the thread replies
- * @param roomId the roomId of the the thread
- * @return
+ * Create an EventEntity to be added in the TimelineEventEntity
*/
- private suspend fun storeNewEventsIfNeeded(threadList: List, roomId: String): Boolean {
- var eventsSkipped = 0
- monarchy
- .awaitTransaction { realm ->
- val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)
-
- val optimizedThreadSummaryMap = hashMapOf()
- val roomMemberContentsByUser = HashMap()
-
- for (event in threadList.reversed()) {
- if (event.eventId == null || event.senderId == null || event.type == null) {
- eventsSkipped++
- continue
- }
-
- if (EventEntity.where(realm, event.eventId).findFirst() != null) {
- // Skip if event already exists
- eventsSkipped++
- continue
- }
- if (event.isEncrypted()) {
- // Decrypt events that will be stored
- decryptIfNeeded(event, roomId)
- }
-
- handleReaction(realm, event, roomId)
-
- val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
- val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC)
-
- // Sender info
- roomMemberContentsByUser.getOrPut(event.senderId) {
- // If we don't have any new state on this user, get it from db
- val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root
- rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
- }
-
- chunk?.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
- eventEntity.rootThreadEventId?.let {
- // This is a thread event
- optimizedThreadSummaryMap[it] = eventEntity
- } ?: run {
- // This is a normal event or a root thread one
- optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
- }
- }
-
- optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(
- roomId = roomId,
- realm = realm,
- currentUserId = userId,
- shouldUpdateNotifications = false
- )
- }
- Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}")
-
- return eventsSkipped == threadList.size
+ private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity {
+ val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
+ return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
}
/**
* Invoke the event decryption mechanism for a specific event
*/
-
- private fun decryptIfNeeded(event: Event, roomId: String) {
+ private suspend fun decryptIfNeeded(event: Event, roomId: String) {
try {
// Event from sync does not have roomId, so add it to the event first
val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "")
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
index 28c17f38b6..31c7254ed5 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
@@ -128,8 +128,8 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
- override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable {
- return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty)
+ override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable {
+ return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty, isUserLocation)
.also { createLocalEcho(it) }
.let { sendEvent(it) }
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
index 718734c5b6..82116cd177 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
@@ -138,16 +138,11 @@ internal class LocalEchoEventFactory @Inject constructor(
options: List,
pollType: PollType): MessagePollContent {
return MessagePollContent(
- pollCreationInfo = PollCreationInfo(
- question = PollQuestion(
- question = question
- ),
+ unstablePollCreationInfo = PollCreationInfo(
+ question = PollQuestion(unstableQuestion = question),
kind = pollType,
answers = options.map { option ->
- PollAnswer(
- id = UUID.randomUUID().toString(),
- answer = option
- )
+ PollAnswer(id = UUID.randomUUID().toString(), unstableAnswer = option)
}
)
)
@@ -168,7 +163,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
- type = EventType.POLL_START,
+ type = EventType.POLL_START.first(),
content = newContent.toContent()
)
}
@@ -180,11 +175,9 @@ internal class LocalEchoEventFactory @Inject constructor(
body = answerId,
relatesTo = RelationDefaultContent(
type = RelationType.REFERENCE,
- eventId = pollEventId),
- response = PollResponse(
- answers = listOf(answerId)
- )
-
+ eventId = pollEventId
+ ),
+ unstableResponse = PollResponse(answers = listOf(answerId))
)
val localId = LocalEcho.createLocalEchoId()
return Event(
@@ -192,7 +185,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
- type = EventType.POLL_RESPONSE,
+ type = EventType.POLL_RESPONSE.first(),
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))
}
@@ -208,7 +201,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
- type = EventType.POLL_START,
+ type = EventType.POLL_START.first(),
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))
}
@@ -227,7 +220,7 @@ internal class LocalEchoEventFactory @Inject constructor(
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
- type = EventType.POLL_END,
+ type = EventType.POLL_END.first(),
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))
}
@@ -235,20 +228,17 @@ internal class LocalEchoEventFactory @Inject constructor(
fun createLocationEvent(roomId: String,
latitude: Double,
longitude: Double,
- uncertainty: Double?): Event {
+ uncertainty: Double?,
+ isUserLocation: Boolean): Event {
val geoUri = buildGeoUri(latitude, longitude, uncertainty)
+ val assetType = if (isUserLocation) LocationAssetType.SELF else LocationAssetType.PIN
val content = MessageLocationContent(
geoUri = geoUri,
body = geoUri,
- locationInfo = LocationInfo(
- geoUri = geoUri,
- description = geoUri
- ),
- locationAsset = LocationAsset(
- type = LocationAssetType.SELF
- ),
- ts = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
- text = geoUri
+ unstableLocationInfo = LocationInfo(geoUri = geoUri, description = geoUri),
+ unstableLocationAsset = LocationAsset(type = assetType),
+ unstableTs = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
+ unstableText = geoUri
)
return createMessageEvent(roomId, content)
}
@@ -354,8 +344,9 @@ internal class LocalEchoEventFactory @Inject constructor(
url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
- type = RelationType.IO_THREAD,
+ type = RelationType.THREAD,
eventId = it,
+ isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
@@ -401,8 +392,9 @@ internal class LocalEchoEventFactory @Inject constructor(
url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
- type = RelationType.IO_THREAD,
+ type = RelationType.THREAD,
eventId = it,
+ isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
@@ -431,8 +423,9 @@ internal class LocalEchoEventFactory @Inject constructor(
voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
- type = RelationType.IO_THREAD,
+ type = RelationType.THREAD,
eventId = it,
+ isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
@@ -451,8 +444,9 @@ internal class LocalEchoEventFactory @Inject constructor(
url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let {
RelationDefaultContent(
- type = RelationType.IO_THREAD,
+ type = RelationType.THREAD,
eventId = it,
+ isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
@@ -484,7 +478,7 @@ internal class LocalEchoEventFactory @Inject constructor(
private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? {
var newContent: Content? = null
if (type == EventType.STICKER) {
- val isThread = (content.toModel())?.relatesTo?.type == RelationType.IO_THREAD
+ val isThread = (content.toModel())?.relatesTo?.type == RelationType.THREAD
val rootThreadEventId = (content.toModel())?.relatesTo?.eventId
if (isThread && rootThreadEventId != null) {
val newRelationalDefaultContent = (content.toModel())?.relatesTo?.copy(
@@ -565,7 +559,7 @@ internal class LocalEchoEventFactory @Inject constructor(
relatesTo = generateReplyRelationContent(
eventId = eventId,
rootThreadEventId = rootThreadEventId,
- showAsReply = showInThread))
+ showInThread = showInThread))
return createMessageEvent(roomId, content)
}
@@ -575,18 +569,20 @@ internal class LocalEchoEventFactory @Inject constructor(
* "m.relates_to": {
* "rel_type": "m.thread",
* "event_id": "$thread_root",
+ * "is_falling_back": false,
* "m.in_reply_to": {
- * "event_id": "$event_target",
- * "render_in": ["m.thread"]
- * }
- * }
+ * "event_id": "$event_target"
+ * }
+ * }
*/
- private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showAsReply: Boolean): RelationDefaultContent =
+ private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showInThread: Boolean): RelationDefaultContent =
rootThreadEventId?.let {
RelationDefaultContent(
- type = RelationType.IO_THREAD,
+ type = RelationType.THREAD,
eventId = it,
- inReplyTo = ReplyToContent(eventId = eventId, renderIn = if (showAsReply) arrayListOf("m.thread") else null))
+ isFallingBack = showInThread,
+ // False when is a rich reply from within a thread, and true when is a reply that should be visible from threads
+ inReplyTo = ReplyToContent(eventId = eventId))
} ?: RelationDefaultContent(null, null, ReplyToContent(eventId = eventId))
private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String {
@@ -643,7 +639,9 @@ internal class LocalEchoEventFactory @Inject constructor(
MessageType.MSGTYPE_AUDIO -> return TextContent("sent an audio file.")
MessageType.MSGTYPE_IMAGE -> return TextContent("sent an image.")
MessageType.MSGTYPE_VIDEO -> return TextContent("sent a video.")
- MessageType.MSGTYPE_POLL_START -> return TextContent((content as? MessagePollContent)?.pollCreationInfo?.question?.question ?: "")
+ MessageType.MSGTYPE_POLL_START -> {
+ return TextContent((content as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "")
+ }
else -> return TextContent(content?.body ?: "")
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt
index 5c629f87f0..93c0167abe 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt
@@ -58,8 +58,9 @@ fun TextContent.toThreadTextContent(
format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null },
body = text,
relatesTo = RelationDefaultContent(
- type = RelationType.IO_THREAD,
+ type = RelationType.THREAD,
eventId = rootThreadEventId,
+ isFallingBack = true,
inReplyTo = ReplyToContent(
eventId = latestThreadEventId
)),
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt
index ccbfbfcded..fa2e0052ab 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt
@@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.session.room.send.pills
import android.text.SpannableString
+import org.matrix.android.sdk.api.session.permalinks.PermalinkService
import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.session.displayname.DisplayNameResolver
@@ -28,7 +29,8 @@ import javax.inject.Inject
*/
internal class TextPillsUtils @Inject constructor(
private val mentionLinkSpecComparator: MentionLinkSpecComparator,
- private val displayNameResolver: DisplayNameResolver
+ private val displayNameResolver: DisplayNameResolver,
+ private val permalinkService: PermalinkService
) {
/**
@@ -36,7 +38,7 @@ internal class TextPillsUtils @Inject constructor(
* @return the transformed String or null if no Span found
*/
fun processSpecialSpansToHtml(text: CharSequence): String? {
- return transformPills(text, MENTION_SPAN_TO_HTML_TEMPLATE)
+ return transformPills(text, permalinkService.createMentionSpanTemplate(PermalinkService.SpanTemplateType.HTML))
}
/**
@@ -44,7 +46,7 @@ internal class TextPillsUtils @Inject constructor(
* @return the transformed String or null if no Span found
*/
fun processSpecialSpansToMarkdown(text: CharSequence): String? {
- return transformPills(text, MENTION_SPAN_TO_MD_TEMPLATE)
+ return transformPills(text, permalinkService.createMentionSpanTemplate(PermalinkService.SpanTemplateType.MARKDOWN))
}
private fun transformPills(text: CharSequence, template: String): String? {
@@ -108,10 +110,4 @@ internal class TextPillsUtils @Inject constructor(
i++
}
}
-
- companion object {
- private const val MENTION_SPAN_TO_HTML_TEMPLATE = "%2\$s"
-
- private const val MENTION_SPAN_TO_MD_TEMPLATE = "[%2\$s](https://matrix.to/#/%1\$s)"
- }
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt
index 7cf5265826..1a01fe6345 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt
@@ -27,6 +27,7 @@ import de.spiritcroc.matrixsdk.StaticScSdkHelper
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.where
+import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.query.ActiveSpaceFilter
import org.matrix.android.sdk.api.query.RoomCategoryFilter
import org.matrix.android.sdk.api.query.isNormalized
@@ -43,6 +44,7 @@ import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotification
import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
+import org.matrix.android.sdk.internal.database.RealmSessionProvider
import org.matrix.android.sdk.internal.database.mapper.RoomSummaryMapper
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields
@@ -57,8 +59,10 @@ import kotlin.math.max
internal class RoomSummaryDataSource @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
+ private val realmSessionProvider: RealmSessionProvider,
private val roomSummaryMapper: RoomSummaryMapper,
- private val queryStringValueProcessor: QueryStringValueProcessor
+ private val queryStringValueProcessor: QueryStringValueProcessor,
+ private val coroutineDispatchers: MatrixCoroutineDispatchers
) {
fun getRoomSummary(roomIdOrAlias: String): RoomSummary? {
@@ -221,14 +225,25 @@ internal class RoomSummaryDataSource @Inject constructor(
return object : UpdatableLivePageResult {
override val livePagedList: LiveData> = mapped
- override fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) {
- realmDataSourceFactory.updateQuery {
- roomSummariesQuery(it, builder.invoke(queryParams)).process(sortOrder)
- }
- }
-
override val liveBoundaries: LiveData
get() = boundaries
+
+ override var queryParams: RoomSummaryQueryParams = queryParams
+ set(value) {
+ field = value
+ realmDataSourceFactory.updateQuery {
+ roomSummariesQuery(it, value).process(sortOrder)
+ }
+ }
+ }
+ }
+
+ fun getCountLive(queryParams: RoomSummaryQueryParams): LiveData {
+ val liveRooms = monarchy.findAllManagedWithChanges {
+ roomSummariesQuery(it, queryParams)
+ }
+ return Transformations.map(liveRooms) {
+ it.realmResults.where().count().toInt()
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt
index d3ca061b0a..4b8cef3691 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.summary
import de.spiritcroc.matrixsdk.StaticScSdkHelper
import io.realm.Realm
import io.realm.kotlin.createObject
+import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.events.model.EventType
@@ -186,7 +187,9 @@ internal class RoomSummaryUpdater @Inject constructor(
Timber.v("Should decrypt ${latestPreviewableOriginalContentEvent.eventId}")
// mmm i want to decrypt now or is it ok to do it async?
tryOrNull {
- eventDecryptor.decryptEvent(root.asDomain(), "")
+ runBlocking {
+ eventDecryptor.decryptEvent(root.asDomain(), "")
+ }
}
?.let { root.setDecryptionResult(it) }
}
@@ -207,7 +210,7 @@ internal class RoomSummaryUpdater @Inject constructor(
}
}
- fun updateRoomPreviews(realm: Realm) {
+ suspend fun updateRoomPreviews(realm: Realm) {
RoomSummaryEntity.where(realm).findAll().forEach { entity ->
val previewEvent = entity.scLatestPreviewableEvent()
val root = previewEvent?.root
@@ -455,8 +458,6 @@ internal class RoomSummaryUpdater @Inject constructor(
realm.where(RoomSummaryEntity::class.java)
.process(RoomSummaryEntityFields.MEMBERSHIP_STR, listOf(Membership.JOIN))
.notEqualTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE)
- // also we do not count DM in here, because home space will already show them
- //.equalTo(RoomSummaryEntityFields.IS_DIRECT, false)
.contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, space.roomId)
.findAll().forEach {
if (!it.isHiddenFromUser) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt
index 5967ae8d2e..b65991347d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt
@@ -23,25 +23,25 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.realm.Realm
import org.matrix.android.sdk.api.session.room.threads.ThreadsService
-import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
-import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
-import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
+import org.matrix.android.sdk.internal.database.helper.enhanceWithEditions
import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId
-import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread
-import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition
+import org.matrix.android.sdk.internal.database.mapper.ThreadSummaryMapper
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
-import org.matrix.android.sdk.internal.database.model.EventEntity
-import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
-import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
-import org.matrix.android.sdk.internal.util.awaitTransaction
+import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask
+import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
internal class DefaultThreadsService @AssistedInject constructor(
@Assisted private val roomId: String,
@UserId private val userId: String,
+ private val fetchThreadTimelineTask: FetchThreadTimelineTask,
+ private val fetchThreadSummariesTask: FetchThreadSummariesTask,
@SessionDatabase private val monarchy: Monarchy,
private val timelineEventMapper: TimelineEventMapper,
+ private val threadSummaryMapper: ThreadSummaryMapper
) : ThreadsService {
@AssistedFactory
@@ -49,55 +49,40 @@ internal class DefaultThreadsService @AssistedInject constructor(
fun create(roomId: String): DefaultThreadsService
}
- override fun getMarkedThreadNotificationsLive(): LiveData> {
+ override fun getAllThreadSummariesLive(): LiveData> {
return monarchy.findAllMappedWithChanges(
- { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
- { timelineEventMapper.map(it) }
+ { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) },
+ {
+ threadSummaryMapper.map(it)
+ }
)
}
- override fun getMarkedThreadNotifications(): List {
+ override fun getAllThreadSummaries(): List {
return monarchy.fetchAllMappedSync(
- { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
- { timelineEventMapper.map(it) }
+ { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) },
+ { threadSummaryMapper.map(it) }
)
}
- override fun getAllThreadsLive(): LiveData> {
- return monarchy.findAllMappedWithChanges(
- { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
- { timelineEventMapper.map(it) }
- )
- }
-
- override fun getAllThreads(): List {
- return monarchy.fetchAllMappedSync(
- { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
- { timelineEventMapper.map(it) }
- )
- }
-
- override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean {
+ override fun enhanceThreadWithEditions(threads: List): List {
return Realm.getInstance(monarchy.realmConfiguration).use {
- TimelineEventEntity.isUserParticipatingInThread(
- realm = it,
- roomId = roomId,
- rootThreadEventId = rootThreadEventId,
- senderId = userId)
+ threads.enhanceWithEditions(it, roomId)
}
}
- override fun mapEventsWithEdition(threads: List): List {
- return Realm.getInstance(monarchy.realmConfiguration).use {
- threads.mapEventsWithEdition(it, roomId)
- }
+ override suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int) {
+ fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(
+ roomId = roomId,
+ rootThreadEventId = rootThreadEventId,
+ from = from,
+ limit = limit
+ ))
}
- override suspend fun markThreadAsRead(rootThreadEventId: String) {
- monarchy.awaitTransaction {
- EventEntity.where(
- realm = it,
- eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE
- }
+ override suspend fun fetchThreadSummaries() {
+ fetchThreadSummariesTask.execute(FetchThreadSummariesTask.Params(
+ roomId = roomId
+ ))
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt
new file mode 100644
index 0000000000..3bc36fb2a8
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.room.threads.local
+
+import androidx.lifecycle.LiveData
+import com.zhuinden.monarchy.Monarchy
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.realm.Realm
+import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
+import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId
+import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId
+import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread
+import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition
+import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
+import org.matrix.android.sdk.internal.database.model.EventEntity
+import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
+import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.di.SessionDatabase
+import org.matrix.android.sdk.internal.di.UserId
+import org.matrix.android.sdk.internal.util.awaitTransaction
+
+internal class DefaultThreadsLocalService @AssistedInject constructor(
+ @Assisted private val roomId: String,
+ @UserId private val userId: String,
+ @SessionDatabase private val monarchy: Monarchy,
+ private val timelineEventMapper: TimelineEventMapper,
+) : ThreadsLocalService {
+
+ @AssistedFactory
+ interface Factory {
+ fun create(roomId: String): DefaultThreadsLocalService
+ }
+
+ override fun getMarkedThreadNotificationsLive(): LiveData> {
+ return monarchy.findAllMappedWithChanges(
+ { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
+ { timelineEventMapper.map(it) }
+ )
+ }
+
+ override fun getMarkedThreadNotifications(): List {
+ return monarchy.fetchAllMappedSync(
+ { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
+ { timelineEventMapper.map(it) }
+ )
+ }
+
+ override fun getAllThreadsLive(): LiveData> {
+ return monarchy.findAllMappedWithChanges(
+ { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
+ { timelineEventMapper.map(it) }
+ )
+ }
+
+ override fun getAllThreads(): List {
+ return monarchy.fetchAllMappedSync(
+ { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
+ { timelineEventMapper.map(it) }
+ )
+ }
+
+ override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean {
+ return Realm.getInstance(monarchy.realmConfiguration).use {
+ TimelineEventEntity.isUserParticipatingInThread(
+ realm = it,
+ roomId = roomId,
+ rootThreadEventId = rootThreadEventId,
+ senderId = userId)
+ }
+ }
+
+ override fun mapEventsWithEdition(threads: List): List {
+ return Realm.getInstance(monarchy.realmConfiguration).use {
+ threads.mapEventsWithEdition(it, roomId)
+ }
+ }
+
+ override suspend fun markThreadAsRead(rootThreadEventId: String) {
+ monarchy.awaitTransaction {
+ EventEntity.where(
+ realm = it,
+ eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE
+ }
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt
index 23b3912836..56766a4f3b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt
@@ -41,6 +41,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
+import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer
@@ -62,6 +63,7 @@ internal class DefaultTimeline(private val roomId: String,
paginationTask: PaginationTask,
getEventTask: GetContextOfEventTask,
fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
+ fetchThreadTimelineTask: FetchThreadTimelineTask,
timelineEventMapper: TimelineEventMapper,
timelineInput: TimelineInput,
threadsAwarenessHandler: ThreadsAwarenessHandler,
@@ -96,7 +98,9 @@ internal class DefaultTimeline(private val roomId: String,
realm = backgroundRealm,
eventDecryptor = eventDecryptor,
paginationTask = paginationTask,
+ realmConfiguration = realmConfiguration,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
+ fetchThreadTimelineTask = fetchThreadTimelineTask,
getContextOfEventTask = getEventTask,
timelineInput = timelineInput,
timelineEventMapper = timelineEventMapper,
@@ -336,7 +340,13 @@ internal class DefaultTimeline(private val roomId: String,
}
withContext(coroutineDispatchers.main) {
listeners.forEach {
- tryOrNull { it.onTimelineUpdated(snapshot) }
+ if (initialEventId != null && isFromThreadTimeline && snapshot.firstOrNull { it.eventId == initialEventId } == null) {
+ // We are in a thread timeline with a permalink, post update timeline only when the appropriate message have been found
+ tryOrNull { it.onTimelineUpdated(arrayListOf()) }
+ } else {
+ // In all the other cases update timeline as expected
+ tryOrNull { it.onTimelineUpdated(snapshot) }
+ }
}
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
index 8094fee504..1ba2aff191 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
@@ -31,6 +31,7 @@ import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsS
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
+import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
@@ -42,6 +43,7 @@ internal class DefaultTimelineService @AssistedInject constructor(
private val eventDecryptor: TimelineEventDecryptor,
private val paginationTask: PaginationTask,
private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
+ private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val timelineEventMapper: TimelineEventMapper,
private val loadRoomMembersTask: LoadRoomMembersTask,
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
@@ -64,10 +66,11 @@ internal class DefaultTimelineService @AssistedInject constructor(
realmConfiguration = monarchy.realmConfiguration,
coroutineDispatchers = coroutineDispatchers,
paginationTask = paginationTask,
+ fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
timelineEventMapper = timelineEventMapper,
timelineInput = timelineInput,
eventDecryptor = eventDecryptor,
- fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
+ fetchThreadTimelineTask = fetchThreadTimelineTask,
loadRoomMembersTask = loadRoomMembersTask,
readReceiptHandler = readReceiptHandler,
getEventTask = contextOfEventTask,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt
index a40f9bbf1c..b97c7cf673 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt
@@ -19,7 +19,9 @@ package org.matrix.android.sdk.internal.session.room.timeline
import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
+import io.realm.RealmConfiguration
import io.realm.RealmResults
+import io.realm.kotlin.createObject
import kotlinx.coroutines.CompletableDeferred
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.failure.Failure
@@ -28,13 +30,19 @@ import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
+import org.matrix.android.sdk.internal.database.helper.addIfNecessary
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
+import org.matrix.android.sdk.internal.database.model.RoomEntity
+import org.matrix.android.sdk.internal.database.model.deleteAndClearThreadEvents
import org.matrix.android.sdk.internal.database.query.findAllIncludingEvents
+import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread
import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
+import timber.log.Timber
import java.util.concurrent.atomic.AtomicReference
/**
@@ -78,6 +86,8 @@ internal class LoadTimelineStrategy(
val realm: AtomicReference,
val eventDecryptor: TimelineEventDecryptor,
val paginationTask: PaginationTask,
+ val realmConfiguration: RealmConfiguration,
+ val fetchThreadTimelineTask: FetchThreadTimelineTask,
val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
val getContextOfEventTask: GetContextOfEventTask,
val timelineInput: TimelineInput,
@@ -173,6 +183,9 @@ internal class LoadTimelineStrategy(
getContextLatch?.cancel()
chunkEntity = null
timelineChunk = null
+ if (mode is Mode.Thread) {
+ clearThreadChunkEntity(dependencies.realm.get(), mode.rootThreadEventId)
+ }
}
suspend fun loadMore(count: Int, direction: Timeline.Direction, fetchOnServerIfNeeded: Boolean = true): LoadMoreResult {
@@ -192,6 +205,9 @@ internal class LoadTimelineStrategy(
return LoadMoreResult.FAILURE
}
}
+ if (mode is Mode.Thread) {
+ return timelineChunk?.loadMoreThread(count) ?: LoadMoreResult.FAILURE
+ }
return timelineChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE
}
@@ -208,7 +224,7 @@ internal class LoadTimelineStrategy(
}
private fun buildSendingEvents(): List {
- return if (hasReachedLastForward()) {
+ return if (hasReachedLastForward() || mode is Mode.Thread) {
sendingEventsDataSource.buildSendingEvents()
} else {
emptyList()
@@ -228,13 +244,47 @@ internal class LoadTimelineStrategy(
ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId))
}
is Mode.Thread -> {
+ recreateThreadChunkEntity(realm, mode.rootThreadEventId)
ChunkEntity.where(realm, roomId)
- .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
+ .equalTo(ChunkEntityFields.ROOT_THREAD_EVENT_ID, mode.rootThreadEventId)
+ .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true)
.findAll()
}
}
}
+ /**
+ * Clear any existing thread chunk entity and create a new one, with the
+ * rootThreadEventId included
+ */
+ private fun recreateThreadChunkEntity(realm: Realm, rootThreadEventId: String) {
+ realm.executeTransaction {
+ // Lets delete the chunk and start a new one
+ ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let {
+ Timber.i("###THREADS LoadTimelineStrategy [onStart] thread chunk cleared..")
+ }
+ val threadChunk = it.createObject().apply {
+ Timber.i("###THREADS LoadTimelineStrategy [onStart] Created new thread chunk with rootThreadEventId: $rootThreadEventId")
+ this.rootThreadEventId = rootThreadEventId
+ this.isLastForwardThread = true
+ }
+ if (threadChunk.isValid) {
+ RoomEntity.where(it, roomId).findFirst()?.addIfNecessary(threadChunk)
+ }
+ }
+ }
+
+ /**
+ * Clear any existing thread chunk
+ */
+ private fun clearThreadChunkEntity(realm: Realm, rootThreadEventId: String) {
+ realm.executeTransaction {
+ ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let {
+ Timber.i("###THREADS LoadTimelineStrategy [onStop] thread chunk cleared..")
+ }
+ }
+ }
+
private fun hasReachedLastForward(): Boolean {
return timelineChunk?.hasReachedLastForward().orFalse()
}
@@ -250,8 +300,10 @@ internal class LoadTimelineStrategy(
timelineSettings = dependencies.timelineSettings,
roomId = roomId,
timelineId = timelineId,
+ fetchThreadTimelineTask = dependencies.fetchThreadTimelineTask,
eventDecryptor = dependencies.eventDecryptor,
paginationTask = dependencies.paginationTask,
+ realmConfiguration = dependencies.realmConfiguration,
fetchTokenAndPaginateTask = dependencies.fetchTokenAndPaginateTask,
timelineEventMapper = dependencies.timelineEventMapper,
uiEchoManager = uiEchoManager,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt
index 9bb71714e7..21c7f04839 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt
@@ -20,6 +20,7 @@ import de.spiritcroc.matrixsdk.util.DbgUtil
import de.spiritcroc.matrixsdk.util.Dimber
import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener
+import io.realm.RealmConfiguration
import io.realm.RealmObjectChangeListener
import io.realm.RealmQuery
import io.realm.RealmResults
@@ -38,6 +39,8 @@ import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
+import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask
+import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
import timber.log.Timber
import java.util.Collections
@@ -52,8 +55,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
private val timelineSettings: TimelineSettings,
private val roomId: String,
private val timelineId: String,
+ private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val eventDecryptor: TimelineEventDecryptor,
private val paginationTask: PaginationTask,
+ private val realmConfiguration: RealmConfiguration,
private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
private val timelineEventMapper: TimelineEventMapper,
private val uiEchoManager: UIEchoManager? = null,
@@ -166,6 +171,9 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
val loadFromStorage = loadFromStorage(count, direction).also {
logLoadedFromStorage(it, direction)
}
+ if (loadFromStorage.numberOfEvents == 6) {
+ Timber.i("here")
+ }
val offsetCount = count - loadFromStorage.numberOfEvents
@@ -184,6 +192,29 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
}
}
+ /**
+ * This function will fetch more live thread timeline events using the /relations api. It will
+ * always fetch results, while we want our data to be up to dated.
+ */
+ suspend fun loadMoreThread(count: Int, direction: Timeline.Direction = Timeline.Direction.BACKWARDS): LoadMoreResult {
+ val rootThreadEventId = timelineSettings.rootThreadEventId ?: return LoadMoreResult.FAILURE
+ return if (direction == Timeline.Direction.BACKWARDS) {
+ try {
+ fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(
+ roomId,
+ rootThreadEventId,
+ chunkEntity.prevToken,
+ count
+ )).toLoadMoreResult()
+ } catch (failure: Throwable) {
+ Timber.e(failure, "Failed to fetch thread timeline events from the server")
+ LoadMoreResult.FAILURE
+ }
+ } else {
+ LoadMoreResult.FAILURE
+ }
+ }
+
private suspend fun delegateLoadMore(fetchFromServerIfNeeded: Boolean, offsetCount: Int, direction: Timeline.Direction): LoadMoreResult {
return if (direction == Timeline.Direction.FORWARDS) {
val nextChunkEntity = chunkEntity.nextChunk
@@ -458,6 +489,14 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
}
}
+ private fun DefaultFetchThreadTimelineTask.Result.toLoadMoreResult(): LoadMoreResult {
+ return when (this) {
+ DefaultFetchThreadTimelineTask.Result.REACHED_END -> LoadMoreResult.REACHED_END
+ DefaultFetchThreadTimelineTask.Result.SHOULD_FETCH_MORE,
+ DefaultFetchThreadTimelineTask.Result.SUCCESS -> LoadMoreResult.SUCCESS
+ }
+ }
+
private fun getOffsetIndex(): Int {
var offset = 0
var currentNextChunk = nextChunk
@@ -529,6 +568,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
}
}
}
+
if (insertions.isNotEmpty() || modifications.isNotEmpty()) {
onBuiltEvents(true)
}
@@ -562,6 +602,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
timelineId = timelineId,
eventDecryptor = eventDecryptor,
paginationTask = paginationTask,
+ realmConfiguration = realmConfiguration,
+ fetchThreadTimelineTask = fetchThreadTimelineTask,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
timelineEventMapper = timelineEventMapper,
uiEchoManager = uiEchoManager,
@@ -613,7 +655,6 @@ private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmR
.or()
.equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId)
.endGroup()
- .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll()
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt
index 49a8a8b55a..3ddd877b78 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt
@@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.timeline
import io.realm.Realm
import io.realm.RealmConfiguration
+import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.session.crypto.CryptoService
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
@@ -99,7 +100,13 @@ internal class TimelineEventDecryptor @Inject constructor(
}
executor?.execute {
Realm.getInstance(realmConfiguration).use { realm ->
- processDecryptRequest(request, realm)
+ try {
+ runBlocking {
+ processDecryptRequest(request, realm)
+ }
+ } catch (e: InterruptedException) {
+ Timber.i("Decryption got interrupted")
+ }
}
}
}
@@ -115,7 +122,7 @@ internal class TimelineEventDecryptor @Inject constructor(
threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity)
}
}
- private fun processDecryptRequest(request: DecryptionRequest, realm: Realm) {
+ private suspend fun processDecryptRequest(request: DecryptionRequest, realm: Realm) {
val event = request.event
val timelineId = request.timelineId
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt
index 5233e6b628..433dc9d505 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt
@@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
+import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.create
import org.matrix.android.sdk.internal.database.query.find
@@ -50,10 +51,10 @@ import javax.inject.Inject
* Insert Chunk in DB, and eventually link next and previous chunk in db.
*/
internal class TokenChunkEventPersistor @Inject constructor(
- @SessionDatabase private val monarchy: Monarchy,
- @UserId private val userId: String,
- private val lightweightSettingsStorage: LightweightSettingsStorage,
- private val liveEventManager: Lazy) {
+ @SessionDatabase private val monarchy: Monarchy,
+ @UserId private val userId: String,
+ private val lightweightSettingsStorage: LightweightSettingsStorage,
+ private val liveEventManager: Lazy) {
enum class Result {
SHOULD_FETCH_MORE,
@@ -171,9 +172,12 @@ internal class TokenChunkEventPersistor @Inject constructor(
if (event.eventId == null || event.senderId == null) {
return@forEach
}
- // We check for the timeline event with this id
+ // We check for the timeline event with this id, but not in the thread chunk
val eventId = event.eventId
- val existingTimelineEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst()
+ val existingTimelineEvent = TimelineEventEntity
+ .where(realm, roomId, eventId)
+ .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false)
+ .findFirst()
// If it exists, we want to stop here, just link the prevChunk
val existingChunk = existingTimelineEvent?.chunk?.firstOrNull()
if (existingChunk != null) {
@@ -199,7 +203,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
return@processTimelineEvents
}
val ageLocalTs = event.unsignedData?.age?.let { now - it }
- val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
+ var eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) {
val contentToUse = if (direction == PaginationDirection.BACKWARDS) {
event.prevContent
@@ -209,7 +213,11 @@ internal class TokenChunkEventPersistor @Inject constructor(
roomMemberContentsByUser[event.stateKey] = contentToUse.toModel()
}
liveEventManager.get().dispatchPaginatedEventReceived(event, roomId)
- currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser)
+ currentChunk.addTimelineEvent(
+ roomId = roomId,
+ eventEntity = eventEntity,
+ direction = direction,
+ roomMemberContentsByUser = roomMemberContentsByUser)
if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
eventEntity.rootThreadEventId?.let {
// This is a thread event
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt
index c18055e089..e764ab551a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt
@@ -113,71 +113,108 @@ internal class DefaultSpaceService @Inject constructor(
return peekSpaceTask.execute(PeekSpaceTask.Params(spaceId))
}
- override suspend fun querySpaceChildren(spaceId: String,
- suggestedOnly: Boolean?,
- limit: Int?,
- from: String?,
- knownStateList: List?): SpaceHierarchyData {
- return resolveSpaceInfoTask.execute(
- ResolveSpaceInfoTask.Params(
- spaceId = spaceId, limit = limit, maxDepth = 1, from = from, suggestedOnly = suggestedOnly
- )
- ).let { response ->
- val spaceDesc = response.rooms?.firstOrNull { it.roomId == spaceId }
- val root = RoomSummary(
- roomId = spaceDesc?.roomId ?: spaceId,
- roomType = spaceDesc?.roomType,
- name = spaceDesc?.name ?: "",
- displayName = spaceDesc?.name ?: "",
- topic = spaceDesc?.topic ?: "",
- joinedMembersCount = spaceDesc?.numJoinedMembers,
- avatarUrl = spaceDesc?.avatarUrl ?: "",
- encryptionEventTs = null,
- typingUsers = emptyList(),
- isEncrypted = false,
- flattenParentIds = emptyList(),
- canonicalAlias = spaceDesc?.canonicalAlias,
- joinRules = RoomJoinRules.PUBLIC.takeIf { spaceDesc?.worldReadable == true }
- )
- val children = response.rooms
- ?.filter { it.roomId != spaceId }
- ?.flatMap { childSummary ->
- (spaceDesc?.childrenState ?: knownStateList)
- ?.filter { it.stateKey == childSummary.roomId && it.type == EventType.STATE_SPACE_CHILD }
- ?.mapNotNull { childStateEv ->
- // create a child entry for everytime this room is the child of a space
- // beware that a room could appear then twice in this list
- childStateEv.content.toModel()?.let { childStateEvContent ->
- SpaceChildInfo(
- childRoomId = childSummary.roomId,
- isKnown = true,
- roomType = childSummary.roomType,
- name = childSummary.name,
- topic = childSummary.topic,
- avatarUrl = childSummary.avatarUrl,
- order = childStateEvContent.order,
-// autoJoin = childStateEvContent.autoJoin ?: false,
- viaServers = childStateEvContent.via.orEmpty(),
- activeMemberCount = childSummary.numJoinedMembers,
- parentRoomId = childStateEv.roomId,
- suggested = childStateEvContent.suggested,
- canonicalAlias = childSummary.canonicalAlias,
- aliases = childSummary.aliases,
- worldReadable = childSummary.worldReadable
- )
- }
- }.orEmpty()
- }
- .orEmpty()
- SpaceHierarchyData(
- rootSummary = root,
- children = children,
- childrenState = spaceDesc?.childrenState.orEmpty(),
- nextToken = response.nextBatch
- )
- }
+ override suspend fun querySpaceChildren(
+ spaceId: String,
+ suggestedOnly: Boolean?,
+ limit: Int?,
+ from: String?,
+ knownStateList: List?
+ ): SpaceHierarchyData {
+ val spacesResponse = getSpacesResponse(spaceId, suggestedOnly, limit, from)
+ val spaceRootResponse = spacesResponse.getRoot(spaceId)
+ val spaceRoot = spaceRootResponse?.toRoomSummary() ?: createBlankRoomSummary(spaceId)
+ val spaceChildren = spacesResponse.rooms.mapSpaceChildren(spaceId, spaceRootResponse, knownStateList)
+
+ return SpaceHierarchyData(
+ rootSummary = spaceRoot,
+ children = spaceChildren,
+ childrenState = spaceRootResponse?.childrenState.orEmpty(),
+ nextToken = spacesResponse.nextBatch
+ )
}
+ private suspend fun getSpacesResponse(spaceId: String, suggestedOnly: Boolean?, limit: Int?, from: String?) =
+ resolveSpaceInfoTask.execute(
+ ResolveSpaceInfoTask.Params(spaceId = spaceId, limit = limit, maxDepth = 1, from = from, suggestedOnly = suggestedOnly)
+ )
+
+ private fun SpacesResponse.getRoot(spaceId: String) = rooms?.firstOrNull { it.roomId == spaceId }
+
+ private fun SpaceChildSummaryResponse.toRoomSummary() = RoomSummary(
+ roomId = roomId,
+ roomType = roomType,
+ name = name ?: "",
+ displayName = name ?: "",
+ topic = topic ?: "",
+ joinedMembersCount = numJoinedMembers,
+ avatarUrl = avatarUrl ?: "",
+ encryptionEventTs = null,
+ typingUsers = emptyList(),
+ isEncrypted = false,
+ flattenParentIds = emptyList(),
+ canonicalAlias = canonicalAlias,
+ joinRules = RoomJoinRules.PUBLIC.takeIf { isWorldReadable }
+ )
+
+ private fun createBlankRoomSummary(spaceId: String) = RoomSummary(
+ roomId = spaceId,
+ joinedMembersCount = null,
+ encryptionEventTs = null,
+ typingUsers = emptyList(),
+ isEncrypted = false,
+ flattenParentIds = emptyList(),
+ canonicalAlias = null,
+ joinRules = null
+ )
+
+ private fun List?.mapSpaceChildren(
+ spaceId: String,
+ spaceRootResponse: SpaceChildSummaryResponse?,
+ knownStateList: List?,
+ ) = this?.filterIdIsNot(spaceId)
+ ?.toSpaceChildInfoList(spaceId, spaceRootResponse, knownStateList)
+ .orEmpty()
+
+ private fun List.filterIdIsNot(spaceId: String) = filter { it.roomId != spaceId }
+
+ private fun List.toSpaceChildInfoList(
+ spaceId: String,
+ rootRoomResponse: SpaceChildSummaryResponse?,
+ knownStateList: List?,
+ ) = flatMap { spaceChildSummary ->
+ (rootRoomResponse?.childrenState ?: knownStateList)
+ ?.filter { it.isChildOf(spaceChildSummary) }
+ ?.mapNotNull { childStateEvent -> childStateEvent.toSpaceChildInfo(spaceId, spaceChildSummary) }
+ .orEmpty()
+ }
+
+ private fun Event.isChildOf(space: SpaceChildSummaryResponse) = stateKey == space.roomId && type == EventType.STATE_SPACE_CHILD
+
+ private fun Event.toSpaceChildInfo(spaceId: String, summary: SpaceChildSummaryResponse) = content.toModel()?.let { content ->
+ createSpaceChildInfo(spaceId, summary, content)
+ }
+
+ private fun createSpaceChildInfo(
+ spaceId: String,
+ summary: SpaceChildSummaryResponse,
+ content: SpaceChildContent
+ ) = SpaceChildInfo(
+ childRoomId = summary.roomId,
+ isKnown = true,
+ roomType = summary.roomType,
+ name = summary.name,
+ topic = summary.topic,
+ avatarUrl = summary.avatarUrl,
+ order = content.order,
+ viaServers = content.via.orEmpty(),
+ activeMemberCount = summary.numJoinedMembers,
+ parentRoomId = spaceId,
+ suggested = content.suggested,
+ canonicalAlias = summary.canonicalAlias,
+ aliases = summary.aliases,
+ worldReadable = summary.isWorldReadable
+ )
+
override suspend fun joinSpace(spaceIdOrAlias: String,
reason: String?,
viaServers: List): JoinSpaceResult {
@@ -192,10 +229,6 @@ internal class DefaultSpaceService @Inject constructor(
leaveRoomTask.execute(LeaveRoomTask.Params(spaceId, reason))
}
-// override fun getSpaceParentsOfRoom(roomId: String): List {
-// return spaceSummaryDataSource.getParentsOfRoom(roomId)
-// }
-
override suspend fun setSpaceParent(childRoomId: String, parentSpaceId: String, canonical: Boolean, viaServers: List) {
// Should we perform some validation here?,
// and if client want to bypass, it could use sendStateEvent directly?
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt
index 2a396d6ee7..d59ca06c2c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.space
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
+import retrofit2.HttpException
import javax.inject.Inject
internal interface ResolveSpaceInfoTask : Task {
@@ -28,7 +29,6 @@ internal interface ResolveSpaceInfoTask : Task
+ // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local)
measureTimeMillis {
Timber.v("Handle rooms")
reportSubtask(reporter, InitSyncStep.ImportingAccountRoom, 1, 0.7f) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt
index f299d3effa..9ae7b82777 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt
@@ -38,7 +38,7 @@ private val loggerTag = LoggerTag("CryptoSyncHandler", LoggerTag.CRYPTO)
internal class CryptoSyncHandler @Inject constructor(private val cryptoService: DefaultCryptoService,
private val verificationService: DefaultVerificationService) {
- fun handleToDevice(toDevice: ToDeviceSyncResponse, progressReporter: ProgressReporter? = null) {
+ suspend fun handleToDevice(toDevice: ToDeviceSyncResponse, progressReporter: ProgressReporter? = null) {
val total = toDevice.events?.size ?: 0
toDevice.events?.forEachIndexed { index, event ->
progressReporter?.reportProgress(index * 100F / total)
@@ -66,7 +66,7 @@ internal class CryptoSyncHandler @Inject constructor(private val cryptoService:
* @param timelineId the timeline identifier
* @return true if the event has been decrypted
*/
- private fun decryptToDeviceEvent(event: Event, timelineId: String?): Boolean {
+ private suspend fun decryptToDeviceEvent(event: Event, timelineId: String?): Boolean {
Timber.v("## CRYPTO | decryptToDeviceEvent")
if (event.getClearType() == EventType.ENCRYPTED) {
var result: MXEventDecryptionResult? = null
@@ -80,6 +80,8 @@ internal class CryptoSyncHandler @Inject constructor(private val cryptoService:
it.identityKey() == senderKey
}?.deviceId ?: senderKey
Timber.e("## CRYPTO | Failed to decrypt to device event from ${event.senderId}|$deviceId reason:<${event.mCryptoError ?: exception}>")
+ } catch (failure: Throwable) {
+ Timber.e(failure, "## CRYPTO | Failed to decrypt to device event from ${event.senderId}")
}
if (null != result) {
@@ -91,7 +93,9 @@ internal class CryptoSyncHandler @Inject constructor(private val cryptoService:
)
return true
} else {
- // should not happen
+ // Could happen for to device events
+ // None of the known session could decrypt the message
+ // In this case unwedging process might have been started (rate limited)
Timber.e("## CRYPTO | ERROR NULL DECRYPTION RESULT from ${event.senderId}")
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt
index fe173a35c3..e5bed12181 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/PresenceSyncHandler.kt
@@ -17,6 +17,7 @@
package org.matrix.android.sdk.internal.session.sync.handler
import io.realm.Realm
+import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.getPresenceContent
import org.matrix.android.sdk.api.session.sync.model.PresenceSyncResponse
@@ -27,27 +28,29 @@ import org.matrix.android.sdk.internal.database.query.updateDirectUserPresence
import org.matrix.android.sdk.internal.database.query.updateUserPresence
import javax.inject.Inject
-internal class PresenceSyncHandler @Inject constructor() {
+internal class PresenceSyncHandler @Inject constructor(private val matrixConfiguration: MatrixConfiguration) {
fun handle(realm: Realm, presenceSyncResponse: PresenceSyncResponse?) {
- presenceSyncResponse?.events
- ?.filter { event -> event.type == EventType.PRESENCE }
- ?.forEach { event ->
- val content = event.getPresenceContent() ?: return@forEach
- val userId = event.senderId ?: return@forEach
- val userPresenceEntity = UserPresenceEntity(
- userId = userId,
- lastActiveAgo = content.lastActiveAgo,
- statusMessage = content.statusMessage,
- isCurrentlyActive = content.isCurrentlyActive,
- avatarUrl = content.avatarUrl,
- displayName = content.displayName
- ).also {
- it.presence = content.presence
- }
+ if (matrixConfiguration.presenceSyncEnabled) {
+ presenceSyncResponse?.events
+ ?.filter { event -> event.type == EventType.PRESENCE }
+ ?.forEach { event ->
+ val content = event.getPresenceContent() ?: return@forEach
+ val userId = event.senderId ?: return@forEach
+ val userPresenceEntity = UserPresenceEntity(
+ userId = userId,
+ lastActiveAgo = content.lastActiveAgo,
+ statusMessage = content.statusMessage,
+ isCurrentlyActive = content.isCurrentlyActive,
+ avatarUrl = content.avatarUrl,
+ displayName = content.displayName
+ ).also {
+ it.presence = content.presence
+ }
- storePresenceToDB(realm, userPresenceEntity)
- }
+ storePresenceToDB(realm, userPresenceEntity)
+ }
+ }
}
/**
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt
index 1a3920f0e2..2991dc25db 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt
@@ -19,14 +19,17 @@ package org.matrix.android.sdk.internal.session.sync.handler.room
import dagger.Lazy
import io.realm.Realm
import io.realm.kotlin.createObject
+import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.session.initsync.InitSyncStep
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType
import org.matrix.android.sdk.api.session.sync.model.InvitedRoomSync
import org.matrix.android.sdk.api.session.sync.model.LazyRoomSyncEphemeral
import org.matrix.android.sdk.api.session.sync.model.RoomSync
@@ -36,6 +39,7 @@ import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.helper.addIfNecessary
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
+import org.matrix.android.sdk.internal.database.helper.createOrUpdate
import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.asDomain
@@ -46,10 +50,13 @@ import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity
+import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.deleteOnCascade
+import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
+import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.database.query.where
@@ -84,6 +91,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource,
@UserId private val userId: String,
+ private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val timelineInput: TimelineInput,
private val liveEventService: Lazy) {
@@ -94,11 +102,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
data class LEFT(val data: Map) : HandlingStrategy()
}
- fun handle(realm: Realm,
- roomsSyncResponse: RoomsSyncResponse,
- isInitialSync: Boolean,
- aggregator: SyncResponsePostTreatmentAggregator,
- reporter: ProgressReporter? = null) {
+ suspend fun handle(realm: Realm,
+ roomsSyncResponse: RoomsSyncResponse,
+ isInitialSync: Boolean,
+ aggregator: SyncResponsePostTreatmentAggregator,
+ reporter: ProgressReporter? = null) {
Timber.v("Execute transaction from $this")
handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter)
handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), isInitialSync, aggregator, reporter)
@@ -113,11 +121,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
}
// PRIVATE METHODS *****************************************************************************
- private fun handleRoomSync(realm: Realm,
- handlingStrategy: HandlingStrategy,
- isInitialSync: Boolean,
- aggregator: SyncResponsePostTreatmentAggregator,
- reporter: ProgressReporter?) {
+ private suspend fun handleRoomSync(realm: Realm,
+ handlingStrategy: HandlingStrategy,
+ isInitialSync: Boolean,
+ aggregator: SyncResponsePostTreatmentAggregator,
+ reporter: ProgressReporter?) {
val insertType = if (isInitialSync) {
EventInsertType.INITIAL_SYNC
} else {
@@ -150,11 +158,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
realm.insertOrUpdate(rooms)
}
- private fun insertJoinRoomsFromInitSync(realm: Realm,
- handlingStrategy: HandlingStrategy.JOINED,
- syncLocalTimeStampMillis: Long,
- aggregator: SyncResponsePostTreatmentAggregator,
- reporter: ProgressReporter?) {
+ private suspend fun insertJoinRoomsFromInitSync(realm: Realm,
+ handlingStrategy: HandlingStrategy.JOINED,
+ syncLocalTimeStampMillis: Long,
+ aggregator: SyncResponsePostTreatmentAggregator,
+ reporter: ProgressReporter?) {
val bestChunkSize = computeBestChunkSize(
listSize = handlingStrategy.data.keys.size,
limit = (initialSyncStrategy as? InitialSyncStrategy.Optimized)?.maxRoomsToInsert ?: Int.MAX_VALUE
@@ -192,12 +200,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
}
}
- private fun handleJoinedRoom(realm: Realm,
- roomId: String,
- roomSync: RoomSync,
- insertType: EventInsertType,
- syncLocalTimestampMillis: Long,
- aggregator: SyncResponsePostTreatmentAggregator): RoomEntity {
+ private suspend fun handleJoinedRoom(realm: Realm,
+ roomId: String,
+ roomSync: RoomSync,
+ insertType: EventInsertType,
+ syncLocalTimestampMillis: Long,
+ aggregator: SyncResponsePostTreatmentAggregator): RoomEntity {
Timber.v("Handle join sync for room $roomId")
val ephemeralResult = (roomSync.ephemeral as? LazyRoomSyncEphemeral.Parsed)
@@ -344,15 +352,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return roomEntity
}
- private fun handleTimelineEvents(realm: Realm,
- roomId: String,
- roomEntity: RoomEntity,
- eventList: List,
- prevToken: String? = null,
- isLimited: Boolean = true,
- insertType: EventInsertType,
- syncLocalTimestampMillis: Long,
- aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity {
+ private suspend fun handleTimelineEvents(realm: Realm,
+ roomId: String,
+ roomEntity: RoomEntity,
+ eventList: List,
+ prevToken: String? = null,
+ isLimited: Boolean = true,
+ insertType: EventInsertType,
+ syncLocalTimestampMillis: Long,
+ aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity {
val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId)
if (isLimited && lastChunk != null) {
lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true)
@@ -380,7 +388,9 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
val isInitialSync = insertType == EventInsertType.INITIAL_SYNC
if (event.isEncrypted() && !isInitialSync) {
- decryptIfNeeded(event, roomId)
+ runBlocking {
+ decryptIfNeeded(event, roomId)
+ }
}
var contentToInject: String? = null
if (!isInitialSync) {
@@ -407,11 +417,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
}
- chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
+ val timelineEventAdded = chunkEntity.addTimelineEvent(
+ roomId = roomId,
+ eventEntity = eventEntity,
+ direction = PaginationDirection.FORWARDS,
+ roomMemberContentsByUser = roomMemberContentsByUser)
if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
eventEntity.rootThreadEventId?.let {
// This is a thread event
optimizedThreadSummaryMap[it] = eventEntity
+ // Add the same thread timeline event to Thread Chunk
+ addToThreadChunkIfNeeded(realm, roomId, it, timelineEventAdded, roomEntity)
+ if (homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreading) {
+ // Update thread summaries only if homeserver supports threading
+ ThreadSummaryEntity.createOrUpdate(
+ threadSummaryType = ThreadSummaryUpdateType.ADD,
+ realm = realm,
+ roomId = roomId,
+ threadEventEntity = eventEntity,
+ roomMemberContentsByUser = roomMemberContentsByUser,
+ userId = userId,
+ roomEntity = roomEntity)
+ }
} ?: run {
// This is a normal event or a root thread one
optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
@@ -456,7 +483,29 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return chunkEntity
}
- private fun decryptIfNeeded(event: Event, roomId: String) {
+ /**
+ * Adds new event to the appropriate thread chunk. If the event is already in
+ * the thread timeline and /relations api, we should not added it
+ */
+ private fun addToThreadChunkIfNeeded(realm: Realm,
+ roomId: String,
+ threadId: String,
+ timelineEventEntity: TimelineEventEntity?,
+ roomEntity: RoomEntity) {
+ val eventId = timelineEventEntity?.eventId ?: return
+
+ ChunkEntity.findLastForwardChunkOfThread(realm, roomId, threadId)?.let { threadChunk ->
+ val existingEvent = threadChunk.timelineEvents.find(eventId)
+ if (existingEvent?.ownedByThreadChunk == true) {
+ Timber.i("###THREADS RoomSyncHandler event:${timelineEventEntity.eventId} already exists, do not add")
+ return@addToThreadChunkIfNeeded
+ }
+ threadChunk.timelineEvents.add(0, timelineEventEntity)
+ roomEntity.addIfNecessary(threadChunk)
+ }
+ }
+
+ private suspend fun decryptIfNeeded(event: Event, roomId: String) {
try {
// Event from sync does not have roomId, so add it to the event first
val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "")
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt
index f3a1523955..db9799d51e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt
@@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
+import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.sync.model.SyncResponse
import org.matrix.android.sdk.api.util.JsonDict
@@ -161,7 +162,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
eventEntity: EventEntity? = null): String? {
event ?: return null
roomId ?: return null
- if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null
+ if (lightweightSettingsStorage.areThreadMessagesEnabled() && !isReplyEvent(event)) return null
handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event)
if (!isThreadEvent(event)) return null
val eventPayload = if (!event.isEncrypted()) {
@@ -170,8 +171,9 @@ internal class ThreadsAwarenessHandler @Inject constructor(
event.mxDecryptionResult?.payload?.toMutableMap() ?: return null
}
val eventBody = event.getDecryptedTextSummary() ?: return null
+ val threadRelation = getRootThreadRelationContent(event)
val eventIdToInject = getPreviousEventOrRoot(event) ?: run {
- return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload)
+ return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload, threadRelation)
}
val eventToInject = getEventFromDB(realm, eventIdToInject)
val eventToInjectBody = eventToInject?.getDecryptedTextSummary()
@@ -183,17 +185,19 @@ internal class ThreadsAwarenessHandler @Inject constructor(
roomId = roomId,
eventBody = eventBody,
eventToInject = eventToInject,
- eventToInjectBody = eventToInjectBody) ?: return null
+ eventToInjectBody = eventToInjectBody,
+ threadRelation = threadRelation) ?: return null
+
// update the event
contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent)
} else {
- contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload)
+ contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload, threadRelation)
}
// Now lets try to find relations for improved results, while some events may come with reverse order
eventEntity?.let {
// When eventEntity is not null means that we are not from within roomSyncHandler
- handleEventsThatRelatesTo(realm, roomId, event, eventBody, false)
+ handleEventsThatRelatesTo(realm, roomId, event, eventBody, false, threadRelation)
}
return contentForNonEncrypted
}
@@ -205,11 +209,16 @@ internal class ThreadsAwarenessHandler @Inject constructor(
* @param event the current event received
* @return The content to inject in the roomSyncHandler live events
*/
- private fun handleRootThreadEventsIfNeeded(realm: Realm, roomId: String, eventEntity: EventEntity?, event: Event): String? {
+ private fun handleRootThreadEventsIfNeeded(
+ realm: Realm,
+ roomId: String,
+ eventEntity: EventEntity?,
+ event: Event
+ ): String? {
if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) {
eventEntity?.let {
val eventBody = event.getDecryptedTextSummary() ?: return null
- return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true)
+ return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true, null)
}
}
return null
@@ -224,7 +233,14 @@ internal class ThreadsAwarenessHandler @Inject constructor(
* @param isFromCache determines whether or not we already know this is root thread event
* @return The content to inject in the roomSyncHandler live events
*/
- private fun handleEventsThatRelatesTo(realm: Realm, roomId: String, event: Event, eventBody: String, isFromCache: Boolean): String? {
+ private fun handleEventsThatRelatesTo(
+ realm: Realm,
+ roomId: String,
+ event: Event,
+ eventBody: String,
+ isFromCache: Boolean,
+ threadRelation: RelationDefaultContent?
+ ): String? {
event.eventId ?: return null
val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null
eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound ->
@@ -236,7 +252,8 @@ internal class ThreadsAwarenessHandler @Inject constructor(
roomId = roomId,
eventBody = newEventBody,
eventToInject = event,
- eventToInjectBody = eventBody) ?: return null
+ eventToInjectBody = eventBody,
+ threadRelation = threadRelation) ?: return null
return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent)
}
@@ -280,7 +297,9 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun injectEvent(roomId: String,
eventBody: String,
eventToInject: Event,
- eventToInjectBody: String): Content? {
+ eventToInjectBody: String,
+ threadRelation: RelationDefaultContent?
+ ): Content? {
val eventToInjectId = eventToInject.eventId ?: return null
val eventIdToInjectSenderId = eventToInject.senderId.orEmpty()
val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false)
@@ -293,6 +312,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
eventBody)
return MessageTextContent(
+ relatesTo = threadRelation,
msgType = MessageType.MSGTYPE_TEXT,
format = MessageFormat.FORMAT_MATRIX_HTML,
body = eventBody,
@@ -306,12 +326,14 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun injectFallbackIndicator(event: Event,
eventBody: String,
eventEntity: EventEntity?,
- eventPayload: MutableMap): String? {
+ eventPayload: MutableMap,
+ threadRelation: RelationDefaultContent?): String? {
val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format(
"In reply to a thread",
eventBody)
val messageTextContent = MessageTextContent(
+ relatesTo = threadRelation,
msgType = MessageType.MSGTYPE_TEXT,
format = MessageFormat.FORMAT_MATRIX_HTML,
body = eventBody,
@@ -332,7 +354,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
.findAll()
cacheEventRootId.add(rootThreadEventId)
return threadList.filter {
- it.asDomain().getRelationContentForType(RelationType.IO_THREAD)?.inReplyTo?.eventId == currentEventId
+ it.asDomain().getRelationContentForType(RelationType.THREAD)?.inReplyTo?.eventId == currentEventId
}
}
@@ -350,7 +372,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
* @param event
*/
private fun isThreadEvent(event: Event): Boolean =
- event.content.toModel()?.relatesTo?.type == RelationType.IO_THREAD
+ event.content.toModel()?.relatesTo?.type == RelationType.THREAD
/**
* Returns the root thread eventId or null otherwise
@@ -359,9 +381,22 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun getRootThreadEventId(event: Event): String? =
event.content.toModel()?.relatesTo?.eventId
+ private fun getRootThreadRelationContent(event: Event): RelationDefaultContent? =
+ event.content.toModel()?.relatesTo
+
private fun getPreviousEventOrRoot(event: Event): String? =
event.content.toModel()?.relatesTo?.inReplyTo?.eventId
+ /**
+ * Returns if we should html inject the current event.
+ */
+ private fun isReplyEvent(event: Event): Boolean {
+ return isThreadEvent(event) && !isFallingBack(event) && getPreviousEventOrRoot(event) != null
+ }
+
+ private fun isFallingBack(event: Event): Boolean =
+ event.content.toModel()?.relatesTo?.isFallingBack == true
+
@Suppress("UNCHECKED_CAST")
private fun getValueFromPayload(payload: JsonDict?, key: String): String? {
val content = payload?.get("content") as? JsonDict
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt
index 96655b849d..088e160950 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.api.auth.data
import org.amshove.kluent.shouldBe
import org.junit.Test
import org.matrix.android.sdk.internal.auth.version.Versions
+import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads
import org.matrix.android.sdk.internal.auth.version.isSupportedBySdk
class VersionsKtTest {
@@ -53,5 +54,20 @@ class VersionsKtTest {
Versions(supportedVersions = listOf("r0.5.0", "r0.6.0")).isSupportedBySdk() shouldBe true
Versions(supportedVersions = listOf("r0.5.0", "r0.6.1")).isSupportedBySdk() shouldBe true
Versions(supportedVersions = listOf("r0.6.0")).isSupportedBySdk() shouldBe true
+ Versions(supportedVersions = listOf("v1.6.0")).isSupportedBySdk() shouldBe true
+ }
+
+ @Test
+ fun doesServerSupportThreads() {
+ Versions(supportedVersions = listOf("r0.6.0")).doesServerSupportThreads() shouldBe false
+ Versions(supportedVersions = listOf("r0.9.1")).doesServerSupportThreads() shouldBe false
+ Versions(supportedVersions = listOf("v1.2.0")).doesServerSupportThreads() shouldBe false
+ Versions(supportedVersions = listOf("v1.3.0")).doesServerSupportThreads() shouldBe true
+ Versions(supportedVersions = listOf("v1.3.1")).doesServerSupportThreads() shouldBe true
+ Versions(supportedVersions = listOf("v1.5.1")).doesServerSupportThreads() shouldBe true
+ Versions(supportedVersions = listOf("r0.6.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to true)).doesServerSupportThreads() shouldBe true
+ Versions(supportedVersions = listOf("v1.2.1"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to true)).doesServerSupportThreads() shouldBe true
+ Versions(supportedVersions = listOf("r0.6.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to false)).doesServerSupportThreads() shouldBe false
+ Versions(supportedVersions = listOf("v1.4.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to false)).doesServerSupportThreads() shouldBe true
}
}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/space/DefaultResolveSpaceInfoTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/space/DefaultResolveSpaceInfoTaskTest.kt
new file mode 100644
index 0000000000..f80c0f06d0
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/space/DefaultResolveSpaceInfoTaskTest.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2021 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.space
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runBlockingTest
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.Test
+import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver
+import org.matrix.android.sdk.test.fakes.FakeSpaceApi
+import org.matrix.android.sdk.test.fixtures.SpacesResponseFixture.aSpacesResponse
+import retrofit2.HttpException
+import retrofit2.Response
+
+@ExperimentalCoroutinesApi
+internal class DefaultResolveSpaceInfoTaskTest {
+
+ private val spaceApi = FakeSpaceApi()
+ private val globalErrorReceiver = FakeGlobalErrorReceiver()
+ private val resolveSpaceInfoTask = DefaultResolveSpaceInfoTask(spaceApi.instance, globalErrorReceiver)
+
+ @Test
+ fun `given stable endpoint works, when execute, then return stable api data`() = runBlockingTest {
+ spaceApi.givenStableEndpointReturns(response)
+
+ val result = resolveSpaceInfoTask.execute(spaceApi.params)
+
+ result shouldBeEqualTo response
+ }
+
+ @Test
+ fun `given stable endpoint fails, when execute, then fallback to unstable endpoint`() = runBlockingTest {
+ spaceApi.givenStableEndpointThrows(httpException)
+ spaceApi.givenUnstableEndpointReturns(response)
+
+ val result = resolveSpaceInfoTask.execute(spaceApi.params)
+
+ result shouldBeEqualTo response
+ }
+
+ companion object {
+ private val response = aSpacesResponse()
+ private val httpException = HttpException(Response.error(500, "".toResponseBody()))
+ }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSpaceApi.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSpaceApi.kt
new file mode 100644
index 0000000000..d4fc986791
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSpaceApi.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2021 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.test.fakes
+
+import io.mockk.coEvery
+import io.mockk.mockk
+import org.matrix.android.sdk.internal.session.space.SpaceApi
+import org.matrix.android.sdk.internal.session.space.SpacesResponse
+import org.matrix.android.sdk.test.fixtures.ResolveSpaceInfoTaskParamsFixture
+
+internal class FakeSpaceApi {
+
+ val instance: SpaceApi = mockk()
+ val params = ResolveSpaceInfoTaskParamsFixture.aResolveSpaceInfoTaskParams()
+
+ fun givenStableEndpointReturns(response: SpacesResponse) {
+ coEvery { instance.getSpaceHierarchy(params.spaceId, params.suggestedOnly, params.limit, params.maxDepth, params.from) } returns response
+ }
+
+ fun givenStableEndpointThrows(throwable: Throwable) {
+ coEvery { instance.getSpaceHierarchy(params.spaceId, params.suggestedOnly, params.limit, params.maxDepth, params.from) } throws throwable
+ }
+
+ fun givenUnstableEndpointReturns(response: SpacesResponse) {
+ coEvery { instance.getSpaceHierarchyUnstable(params.spaceId, params.suggestedOnly, params.limit, params.maxDepth, params.from) } returns response
+ }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/ResolveSpaceInfoTaskParamsFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/ResolveSpaceInfoTaskParamsFixture.kt
new file mode 100644
index 0000000000..28f8c3637d
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/ResolveSpaceInfoTaskParamsFixture.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2021 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.test.fixtures
+
+import org.matrix.android.sdk.internal.session.space.ResolveSpaceInfoTask
+
+internal object ResolveSpaceInfoTaskParamsFixture {
+ fun aResolveSpaceInfoTaskParams(
+ spaceId: String = "",
+ limit: Int? = null,
+ maxDepth: Int? = null,
+ from: String? = null,
+ suggestedOnly: Boolean? = null,
+ ) = ResolveSpaceInfoTask.Params(
+ spaceId,
+ limit,
+ maxDepth,
+ from,
+ suggestedOnly,
+ )
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SpacesResponseFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SpacesResponseFixture.kt
new file mode 100644
index 0000000000..0a08331114
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SpacesResponseFixture.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.test.fixtures
+
+import org.matrix.android.sdk.internal.session.space.SpaceChildSummaryResponse
+import org.matrix.android.sdk.internal.session.space.SpacesResponse
+
+internal object SpacesResponseFixture {
+ fun aSpacesResponse(
+ nextBatch: String? = null,
+ rooms: List? = null,
+ ) = SpacesResponse(
+ nextBatch,
+ rooms,
+ )
+}
diff --git a/tools/ci/render_test_output.py b/tools/ci/render_test_output.py
index 1e7940ce04..f955b93cf9 100755
--- a/tools/ci/render_test_output.py
+++ b/tools/ci/render_test_output.py
@@ -13,32 +13,35 @@ print("::group::Arguments")
print(f"{sys.argv}")
print("::endgroup::")
for xmlfile in xmlfiles:
- tree = ET.parse(xmlfile)
+ try:
+ tree = ET.parse(xmlfile)
- root = tree.getroot()
- name = root.attrib['name']
- time = root.attrib['time']
- tests = int(root.attrib['tests'])
- skipped = int(root.attrib['skipped'])
- errors = int(root.attrib['errors'])
- failures = int(root.attrib['failures'])
- success = tests - failures - errors - skipped
- total = tests - skipped
- print(f"::group::{name} {success}/{total} ({skipped} skipped) in {time}")
-
- for testcase in root:
- if testcase.tag != "testcase":
- continue
- testname = testcase.attrib['classname']
- message = testcase.attrib['name']
- time = testcase.attrib['time']
- child = testcase.find("failure")
- if child is None:
- print(f"{message} in {time}s")
- else:
- print(f"::error file={testname}::{message} in {time}s")
- print(child.text)
- body = f"passed={success} failures={failures} errors={errors} skipped={skipped}"
- print(f"::set-output name={suitename}::={body}")
+ root = tree.getroot()
+ name = root.attrib['name']
+ time = root.attrib['time']
+ tests = int(root.attrib['tests'])
+ skipped = int(root.attrib['skipped'])
+ errors = int(root.attrib['errors'])
+ failures = int(root.attrib['failures'])
+ success = tests - failures - errors - skipped
+ total = tests - skipped
+ print(f"::group::{name} {success}/{total} ({skipped} skipped) in {time}")
+
+ for testcase in root:
+ if testcase.tag != "testcase":
+ continue
+ testname = testcase.attrib['classname']
+ message = testcase.attrib['name']
+ time = testcase.attrib['time']
+ child = testcase.find("failure")
+ if child is None:
+ print(f"{message} in {time}s")
+ else:
+ print(f"::error file={testname}::{message} in {time}s")
+ print(child.text)
+ body = f" passed={success} failures={failures} errors={errors} skipped={skipped}"
+ print(f"::set-output name={suitename}::={body}")
+ except FileNotFoundError:
+ print(f"::error::Unable to open test results file {xmlfile} - check if the tests completed")
print("::endgroup::")
diff --git a/tools/emojis/emoji_picker_datasource_formatted.json b/tools/emojis/emoji_picker_datasource_formatted.json
index c1aa590003..a1b944a7eb 100644
--- a/tools/emojis/emoji_picker_datasource_formatted.json
+++ b/tools/emojis/emoji_picker_datasource_formatted.json
@@ -2056,7 +2056,9 @@
"disappear",
"dissolve",
"liquid",
- "melt"
+ "melt",
+ "hot",
+ "heat"
]
},
"winking-face": {
@@ -2351,7 +2353,10 @@
"disbelief",
"embarrass",
"scared",
- "surprise"
+ "surprise",
+ "silence",
+ "secret",
+ "shock"
]
},
"face-with-peeking-eye": {
@@ -2360,7 +2365,10 @@
"j": [
"captivated",
"peep",
- "stare"
+ "stare",
+ "scared",
+ "frightening",
+ "embarrassing"
]
},
"shushing-face": {
@@ -2392,7 +2400,8 @@
"salute",
"sunny",
"troops",
- "yes"
+ "yes",
+ "respect"
]
},
"zippermouth-face": {
@@ -2467,7 +2476,10 @@
"disappear",
"hide",
"introvert",
- "invisible"
+ "invisible",
+ "lonely",
+ "isolation",
+ "depression"
]
},
"face-in-clouds": {
@@ -2863,7 +2875,11 @@
"disappointed",
"meh",
"skeptical",
- "unsure"
+ "unsure",
+ "skeptic",
+ "confuse",
+ "frustrated",
+ "indifferent"
]
},
"worried-face": {
@@ -2969,7 +2985,9 @@
"cry",
"proud",
"resist",
- "sad"
+ "sad",
+ "touched",
+ "gratitude"
]
},
"frowning-face-with-open-mouth": {
@@ -4065,7 +4083,9 @@
"j": [
"hand",
"right",
- "rightward"
+ "rightward",
+ "palm",
+ "offer"
]
},
"leftwards-hand": {
@@ -4074,7 +4094,9 @@
"j": [
"hand",
"left",
- "leftward"
+ "leftward",
+ "palm",
+ "offer"
]
},
"palm-down-hand": {
@@ -4083,7 +4105,8 @@
"j": [
"dismiss",
"drop",
- "shoo"
+ "shoo",
+ "palm"
]
},
"palm-up-hand": {
@@ -4093,7 +4116,9 @@
"beckon",
"catch",
"come",
- "offer"
+ "offer",
+ "lift",
+ "demand"
]
},
"ok-hand": {
@@ -4290,7 +4315,8 @@
"b": "1FAF5",
"j": [
"point",
- "you"
+ "you",
+ "recruit"
]
},
"thumbs-up": {
@@ -4404,7 +4430,9 @@
"a": "⊛ Heart Hands",
"b": "1FAF6",
"j": [
- "love"
+ "love",
+ "appreciation",
+ "support"
]
},
"open-hands": {
@@ -4662,7 +4690,11 @@
"flirting",
"nervous",
"uncomfortable",
- "worried"
+ "worried",
+ "flirt",
+ "sexy",
+ "pain",
+ "worry"
]
},
"baby": {
@@ -6058,7 +6090,8 @@
"monarch",
"noble",
"regal",
- "royalty"
+ "royalty",
+ "power"
]
},
"prince": {
@@ -6231,7 +6264,8 @@
"belly",
"bloated",
"full",
- "pregnant"
+ "pregnant",
+ "baby"
]
},
"pregnant-person": {
@@ -6241,7 +6275,8 @@
"belly",
"bloated",
"full",
- "pregnant"
+ "pregnant",
+ "baby"
]
},
"breastfeeding": {
@@ -6635,7 +6670,8 @@
"j": [
"fairy tale",
"fantasy",
- "monster"
+ "monster",
+ "mystical"
]
},
"person-getting-massage": {
@@ -9374,7 +9410,8 @@
"b": "1FAB8",
"j": [
"ocean",
- "reef"
+ "reef",
+ "sea"
]
},
"snail": {
@@ -9587,7 +9624,9 @@
"Hinduism",
"India",
"purity",
- "Vietnam"
+ "Vietnam",
+ "calm",
+ "meditation"
]
},
"rosette": {
@@ -9832,14 +9871,16 @@
"a": "⊛ Empty Nest",
"b": "1FAB9",
"j": [
- "nesting"
+ "nesting",
+ "bird"
]
},
"nest-with-eggs": {
"a": "⊛ Nest with Eggs",
"b": "1FABA",
"j": [
- "nesting"
+ "nesting",
+ "bird"
]
},
"grapes": {
@@ -11187,7 +11228,9 @@
"drink",
"empty",
"glass",
- "spill"
+ "spill",
+ "cup",
+ "water"
]
},
"cup-with-straw": {
@@ -12003,7 +12046,9 @@
"b": "1F6DD",
"j": [
"amusement park",
- "play"
+ "play",
+ "fun",
+ "park"
]
},
"ferris-wheel": {
@@ -12533,7 +12578,9 @@
"j": [
"circle",
"tire",
- "turn"
+ "turn",
+ "car",
+ "transport"
]
},
"police-car-light": {
@@ -14666,7 +14713,8 @@
"hand",
"Mary",
"Miriam",
- "protection"
+ "protection",
+ "religion"
]
},
"video-game": {
@@ -15864,7 +15912,9 @@
"b": "1FAAB",
"j": [
"electronic",
- "low energy"
+ "low energy",
+ "drained",
+ "dead"
]
},
"electric-plug": {
@@ -17508,7 +17558,9 @@
"disability",
"hurt",
"mobility aid",
- "stick"
+ "stick",
+ "accessibility",
+ "assist"
]
},
"stethoscope": {
@@ -17528,7 +17580,9 @@
"bones",
"doctor",
"medical",
- "skeleton"
+ "skeleton",
+ "x-ray",
+ "medicine"
]
},
"door": {
@@ -17733,7 +17787,10 @@
"burp",
"clean",
"soap",
- "underwater"
+ "underwater",
+ "fun",
+ "carbonation",
+ "sparkling"
]
},
"toothbrush": {
@@ -17856,7 +17913,8 @@
"credentials",
"ID",
"license",
- "security"
+ "security",
+ "document"
]
},
"atm-sign": {
diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml
index 0121ee9ae7..40fc68bbae 100755
--- a/vector-config/src/main/res/values/config-settings.xml
+++ b/vector-config/src/main/res/values/config-settings.xml
@@ -36,8 +36,9 @@
+ false
-
+
diff --git a/vector/build.gradle b/vector/build.gradle
index a5950b21cc..ec7c166313 100644
--- a/vector/build.gradle
+++ b/vector/build.gradle
@@ -18,7 +18,7 @@ ext.versionMinor = 4
// Note: even values are reserved for regular release, odd values for hotfix release.
// When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release.
-ext.versionPatch = 4
+ext.versionPatch = 8
ext.scVersion = 50
@@ -153,6 +153,7 @@ android {
buildConfigField "Boolean", "enableLocationSharing", "true"
buildConfigField "String", "mapTilerKey", "\"fU3vlMsMn4Jb6dnEIFsx\""
+ buildConfigField "Boolean", "PRESENCE_SYNC_ENABLED", "true"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -233,6 +234,7 @@ android {
buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false"
// Set to true if you want to enable strict mode in debug
buildConfigField "boolean", "ENABLE_STRICT_MODE_LOGS", "false"
+ buildConfigField "Boolean", "ENABLE_LIVE_LOCATION_SHARING", "true"
signingConfig signingConfigs.debug
}
@@ -242,6 +244,7 @@ android {
buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false"
buildConfigField "boolean", "ENABLE_STRICT_MODE_LOGS", "false"
+ buildConfigField "Boolean", "ENABLE_LIVE_LOCATION_SHARING", "false"
postprocessing {
removeUnusedCode true
@@ -368,6 +371,7 @@ dependencies {
// Lifecycle
implementation libs.androidx.lifecycleLivedata
implementation libs.androidx.lifecycleProcess
+ implementation libs.androidx.lifecycleRuntimeKtx
implementation libs.androidx.datastore
implementation libs.androidx.datastorepreferences
@@ -380,7 +384,7 @@ dependencies {
implementation 'com.facebook.stetho:stetho:1.6.0'
// Phone number https://github.com/google/libphonenumber
- implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.44'
+ implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.45'
// FlowBinding
implementation libs.github.flowBinding
diff --git a/vector/lint.xml b/vector/lint.xml
index 22da6adfa9..e219ac1eed 100644
--- a/vector/lint.xml
+++ b/vector/lint.xml
@@ -15,24 +15,14 @@
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt
index 417d28d625..5a03d5890a 100644
--- a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt
+++ b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt
@@ -27,6 +27,7 @@ import im.vector.app.espresso.tools.ScreenshotFailureRule
import im.vector.app.features.MainActivity
import im.vector.app.getString
import im.vector.app.ui.robot.ElementRobot
+import im.vector.app.ui.robot.settings.labs.LabFeature
import im.vector.app.ui.robot.withDeveloperMode
import org.junit.Rule
import org.junit.Test
@@ -97,6 +98,8 @@ class UiAllScreensSanityTest {
}
}
+ testThreadScreens()
+
elementRobot.space {
createSpace {
crawl()
@@ -148,4 +151,25 @@ class UiAllScreensSanityTest {
// TODO Deactivate account instead of logout?
elementRobot.signout(expectSignOutWarning = false)
}
+
+ /**
+ * Testing multiple threads screens
+ */
+ private fun testThreadScreens() {
+ elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES)
+ elementRobot.newRoom {
+ createNewRoom {
+ crawl()
+ createRoom {
+ val message = "Hello This message will be a thread!"
+ postMessage(message)
+ replyToThread(message)
+ viewInRoom(message)
+ openThreadSummaries()
+ selectThreadSummariesFilter()
+ }
+ }
+ }
+ elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES)
+ }
}
diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt
index f0ce23b7db..3c5de8b221 100644
--- a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt
+++ b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt
@@ -17,9 +17,15 @@
package im.vector.app.ui.robot
import android.view.View
+import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import com.adevinta.android.barista.interaction.BaristaDialogInteractions.clickDialogNegativeButton
@@ -35,6 +41,7 @@ import im.vector.app.features.home.HomeActivity
import im.vector.app.features.onboarding.OnboardingActivity
import im.vector.app.initialSyncIdlingResource
import im.vector.app.ui.robot.settings.SettingsRobot
+import im.vector.app.ui.robot.settings.labs.LabFeature
import im.vector.app.ui.robot.space.SpaceRobot
import im.vector.app.withIdlingResource
import timber.log.Timber
@@ -70,11 +77,11 @@ class ElementRobot {
}
}
- fun settings(block: SettingsRobot.() -> Unit) {
+ fun settings(shouldGoBack: Boolean = true, block: SettingsRobot.() -> Unit) {
openDrawer()
clickOn(R.id.homeDrawerHeaderSettingsView)
block(SettingsRobot())
- pressBack()
+ if (shouldGoBack) pressBack()
waitUntilViewVisible(withId(R.id.bottomNavigationView))
}
@@ -103,6 +110,22 @@ class ElementRobot {
waitUntilViewVisible(withId(R.id.bottomNavigationView))
}
+ fun toggleLabFeature(labFeature: LabFeature) {
+ when (labFeature) {
+ LabFeature.THREAD_MESSAGES -> {
+ settings(shouldGoBack = false) {
+ labs(shouldGoBack = false) {
+ onView(withText(R.string.labs_enable_thread_messages))
+ .check(ViewAssertions.matches(isDisplayed()))
+ .perform(ViewActions.closeSoftKeyboard(), click())
+ }
+ }
+ }
+ else -> {
+ }
+ }
+ }
+
fun signout(expectSignOutWarning: Boolean) {
clickOn(R.id.groupToolbarAvatarImageView)
clickOn(R.id.homeDrawerHeaderSignoutView)
diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt
index 5973dc3473..5c9ecfdef5 100644
--- a/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt
+++ b/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt
@@ -70,4 +70,13 @@ class MessageMenuRobot(
clickOn(R.string.edit)
autoClosed = true
}
+
+ fun replyInThread() {
+ clickOn(R.string.reply_in_thread)
+ autoClosed = true
+ }
+ fun viewInRoom() {
+ clickOn(R.string.view_in_room)
+ autoClosed = true
+ }
}
diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt
index b3bb5172e8..d051488ad7 100644
--- a/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt
+++ b/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt
@@ -21,6 +21,7 @@ import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.assertion.BaristaEnabledAssertions.assertDisabled
import com.adevinta.android.barista.assertion.BaristaEnabledAssertions.assertEnabled
import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed
@@ -55,6 +56,8 @@ class OnboardingRobot {
fun createAccount(userId: String, password: String = "password", homeServerUrl: String = "http://10.0.2.2:8080") {
initSession(true, userId, password, homeServerUrl)
+ waitUntilViewVisible(withText(R.string.ftue_account_created_congratulations_title))
+ clickOn(R.string.ftue_account_created_take_me_home)
}
fun login(userId: String, password: String = "password", homeServerUrl: String = "http://10.0.2.2:8080") {
diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt
index 6cf6ad3551..91409582d9 100644
--- a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt
+++ b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt
@@ -62,6 +62,23 @@ class RoomDetailRobot {
pressBack()
}
+ fun replyToThread(message: String) {
+ openMessageMenu(message) {
+ replyInThread()
+ }
+ val threadMessage = "Hello universe - long message to avoid espresso tapping edited!"
+ writeTo(R.id.composerEditText, threadMessage)
+ waitUntilViewVisible(withId(R.id.sendButton))
+ clickOn(R.id.sendButton)
+ }
+
+ fun viewInRoom(message: String) {
+ openMessageMenu(message) {
+ viewInRoom()
+ }
+ waitUntilViewVisible(withId(R.id.composerEditText))
+ }
+
fun crawlMessage(message: String) {
// Test quick reaction
val quickReaction = EmojiDataSource.quickEmojis[0] // 👍
@@ -110,7 +127,7 @@ class RoomDetailRobot {
onView(withId(R.id.timelineRecyclerView))
.perform(
RecyclerViewActions.actionOnItem(
- ViewMatchers.hasDescendant(ViewMatchers.withText(message)),
+ ViewMatchers.hasDescendant(withText(message)),
ViewActions.longClick()
)
)
@@ -130,4 +147,16 @@ class RoomDetailRobot {
block(RoomSettingsRobot())
pressBack()
}
+
+ fun openThreadSummaries() {
+ clickMenu(R.id.menu_timeline_thread_list)
+ waitUntilViewVisible(withId(R.id.threadListRecyclerView))
+ }
+
+ fun selectThreadSummariesFilter() {
+ clickMenu(R.id.menu_thread_list_filter)
+ sleep(1000)
+ clickOn(R.id.threadListModalMyThreads)
+ pressBack()
+ }
}
diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt
index 561f14c6f2..97aee7ac4a 100644
--- a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt
+++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt
@@ -16,6 +16,7 @@
package im.vector.app.ui.robot.settings
+import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import im.vector.app.R
import im.vector.app.clickOnAndGoBack
@@ -51,8 +52,13 @@ class SettingsRobot {
clickOnAndGoBack(R.string.settings_security_and_privacy) { block(SettingsSecurityRobot()) }
}
- fun labs(block: () -> Unit = {}) {
- clickOnAndGoBack(R.string.room_settings_labs_pref_title) { block() }
+ fun labs(shouldGoBack: Boolean = true, block: () -> Unit = {}) {
+ if (shouldGoBack) {
+ clickOnAndGoBack(R.string.room_settings_labs_pref_title) { block() }
+ } else {
+ clickOn(R.string.room_settings_labs_pref_title)
+ block()
+ }
}
fun advancedSettings(block: SettingsAdvancedRobot.() -> Unit) {
diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt
new file mode 100644
index 0000000000..656201d812
--- /dev/null
+++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.ui.robot.settings.labs
+
+enum class LabFeature {
+ SWIPE_TO_REPLY,
+ TAB_UNREAD_NOTIFICATIONS,
+ LATEX_MATHEMATICS,
+ THREAD_MESSAGES,
+ AUTO_REPORT_ERRORS,
+ RENDER_USER_LOCATION
+}
diff --git a/vector/src/debug/AndroidManifest.xml b/vector/src/debug/AndroidManifest.xml
index 0b2b5cf90f..87aade0c8b 100644
--- a/vector/src/debug/AndroidManifest.xml
+++ b/vector/src/debug/AndroidManifest.xml
@@ -2,6 +2,8 @@
+
+
diff --git a/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorOverrides.kt b/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorOverrides.kt
index 4394f5436e..5e16182f3c 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorOverrides.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorOverrides.kt
@@ -22,13 +22,17 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
+import im.vector.app.features.HomeserverCapabilitiesOverride
import im.vector.app.features.VectorOverrides
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.extensions.orFalse
private val Context.dataStore: DataStore by preferencesDataStore(name = "vector_overrides")
private val keyForceDialPadDisplay = booleanPreferencesKey("force_dial_pad_display")
private val keyForceLoginFallback = booleanPreferencesKey("force_login_fallback")
+private val forceCanChangeDisplayName = booleanPreferencesKey("force_can_change_display_name")
+private val forceCanChangeAvatar = booleanPreferencesKey("force_can_change_avatar")
class DebugVectorOverrides(private val context: Context) : VectorOverrides {
@@ -40,6 +44,13 @@ class DebugVectorOverrides(private val context: Context) : VectorOverrides {
preferences[keyForceLoginFallback].orFalse()
}
+ override val forceHomeserverCapabilities = context.dataStore.data.map { preferences ->
+ HomeserverCapabilitiesOverride(
+ canChangeDisplayName = preferences[forceCanChangeDisplayName],
+ canChangeAvatar = preferences[forceCanChangeAvatar]
+ )
+ }
+
suspend fun setForceDialPadDisplay(force: Boolean) {
context.dataStore.edit { settings ->
settings[keyForceDialPadDisplay] = force
@@ -51,4 +62,18 @@ class DebugVectorOverrides(private val context: Context) : VectorOverrides {
settings[keyForceLoginFallback] = force
}
}
+
+ suspend fun setHomeserverCapabilities(block: HomeserverCapabilitiesOverride.() -> HomeserverCapabilitiesOverride) {
+ val capabilitiesOverride = block(forceHomeserverCapabilities.firstOrNull() ?: HomeserverCapabilitiesOverride(null, null))
+ context.dataStore.edit { settings ->
+ when (capabilitiesOverride.canChangeDisplayName) {
+ null -> settings.remove(forceCanChangeDisplayName)
+ else -> settings[forceCanChangeDisplayName] = capabilitiesOverride.canChangeDisplayName
+ }
+ when (capabilitiesOverride.canChangeAvatar) {
+ null -> settings.remove(forceCanChangeAvatar)
+ else -> settings[forceCanChangeAvatar] = capabilitiesOverride.canChangeAvatar
+ }
+ }
+ }
}
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt
index b54d776901..38253fe7c2 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsFragment.kt
@@ -50,6 +50,12 @@ class DebugPrivateSettingsFragment : VectorBaseFragment
+ viewModel.handle(DebugPrivateSettingsViewActions.SetDisplayNameCapabilityOverride(option))
+ }
+ views.forceChangeAvatarCapability.bind(it.homeserverCapabilityOverrides.avatar) { option ->
+ viewModel.handle(DebugPrivateSettingsViewActions.SetAvatarCapabilityOverride(option))
+ }
views.forceLoginFallback.isChecked = it.forceLoginFallback
}
}
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt
index 1c76cf6fb2..5dea3dce64 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewActions.kt
@@ -18,7 +18,9 @@ package im.vector.app.features.debug.settings
import im.vector.app.core.platform.VectorViewModelAction
-sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
- data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
- data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions()
+sealed interface DebugPrivateSettingsViewActions : VectorViewModelAction {
+ data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions
+ data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions
+ data class SetDisplayNameCapabilityOverride(val option: BooleanHomeserverCapabilitiesOverride?) : DebugPrivateSettingsViewActions
+ data class SetAvatarCapabilityOverride(val option: BooleanHomeserverCapabilitiesOverride?) : DebugPrivateSettingsViewActions
}
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt
index 8d040d4773..62871023bc 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewModel.kt
@@ -22,9 +22,12 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
+import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.debug.features.DebugVectorOverrides
+import im.vector.app.features.debug.settings.DebugPrivateSettingsViewActions.SetAvatarCapabilityOverride
+import im.vector.app.features.debug.settings.DebugPrivateSettingsViewActions.SetDisplayNameCapabilityOverride
import kotlinx.coroutines.launch
class DebugPrivateSettingsViewModel @AssistedInject constructor(
@@ -40,10 +43,10 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory()
init {
- observeVectorDataStore()
+ observeVectorOverrides()
}
- private fun observeVectorDataStore() {
+ private fun observeVectorOverrides() {
debugVectorOverrides.forceDialPad.setOnEach {
copy(
dialPadVisible = it
@@ -52,13 +55,23 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
debugVectorOverrides.forceLoginFallback.setOnEach {
copy(forceLoginFallback = it)
}
+ debugVectorOverrides.forceHomeserverCapabilities.setOnEach {
+ val activeDisplayNameOption = BooleanHomeserverCapabilitiesOverride.from(it.canChangeDisplayName)
+ val activeAvatarOption = BooleanHomeserverCapabilitiesOverride.from(it.canChangeAvatar)
+ copy(homeserverCapabilityOverrides = homeserverCapabilityOverrides.copy(
+ displayName = homeserverCapabilityOverrides.displayName.copy(activeOption = activeDisplayNameOption),
+ avatar = homeserverCapabilityOverrides.avatar.copy(activeOption = activeAvatarOption),
+ ))
+ }
}
override fun handle(action: DebugPrivateSettingsViewActions) {
when (action) {
is DebugPrivateSettingsViewActions.SetDialPadVisibility -> handleSetDialPadVisibility(action)
is DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled -> handleSetForceLoginFallbackEnabled(action)
- }
+ is SetDisplayNameCapabilityOverride -> handSetDisplayNameCapabilityOverride(action)
+ is SetAvatarCapabilityOverride -> handSetAvatarCapabilityOverride(action)
+ }.exhaustive
}
private fun handleSetDialPadVisibility(action: DebugPrivateSettingsViewActions.SetDialPadVisibility) {
@@ -72,4 +85,18 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
debugVectorOverrides.setForceLoginFallback(action.force)
}
}
+
+ private fun handSetDisplayNameCapabilityOverride(action: SetDisplayNameCapabilityOverride) {
+ viewModelScope.launch {
+ val forceDisplayName = action.option.toBoolean()
+ debugVectorOverrides.setHomeserverCapabilities { copy(canChangeDisplayName = forceDisplayName) }
+ }
+ }
+
+ private fun handSetAvatarCapabilityOverride(action: SetAvatarCapabilityOverride) {
+ viewModelScope.launch {
+ val forceAvatar = action.option.toBoolean()
+ debugVectorOverrides.setHomeserverCapabilities { copy(canChangeAvatar = forceAvatar) }
+ }
+ }
}
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt
index 7fca29af8c..749b11a744 100644
--- a/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/DebugPrivateSettingsViewState.kt
@@ -17,8 +17,23 @@
package im.vector.app.features.debug.settings
import com.airbnb.mvrx.MavericksState
+import im.vector.app.features.debug.settings.OverrideDropdownView.OverrideDropdown
data class DebugPrivateSettingsViewState(
val dialPadVisible: Boolean = false,
val forceLoginFallback: Boolean = false,
+ val homeserverCapabilityOverrides: HomeserverCapabilityOverrides = HomeserverCapabilityOverrides()
) : MavericksState
+
+data class HomeserverCapabilityOverrides(
+ val displayName: OverrideDropdown = OverrideDropdown(
+ label = "Override display name capability",
+ activeOption = null,
+ options = listOf(BooleanHomeserverCapabilitiesOverride.ForceEnabled, BooleanHomeserverCapabilitiesOverride.ForceDisabled)
+ ),
+ val avatar: OverrideDropdown = OverrideDropdown(
+ label = "Override avatar capability",
+ activeOption = null,
+ options = listOf(BooleanHomeserverCapabilitiesOverride.ForceEnabled, BooleanHomeserverCapabilitiesOverride.ForceDisabled)
+ )
+)
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/OverrideDropdownView.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/OverrideDropdownView.kt
new file mode 100644
index 0000000000..48ec44f909
--- /dev/null
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/OverrideDropdownView.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.debug.settings
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.LinearLayout
+import im.vector.app.R
+import im.vector.app.databinding.ViewBooleanDropdownBinding
+
+class OverrideDropdownView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : LinearLayout(context, attrs) {
+
+ private val binding = ViewBooleanDropdownBinding.inflate(
+ LayoutInflater.from(context),
+ this
+ )
+
+ init {
+ orientation = HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ }
+
+ fun bind(feature: OverrideDropdown, listener: Listener) {
+ binding.overrideLabel.text = feature.label
+
+ binding.overrideOptions.apply {
+ val arrayAdapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item)
+ val options = listOf("Inactive") + feature.options.map { it.label }
+ arrayAdapter.addAll(options)
+ adapter = arrayAdapter
+
+ feature.activeOption?.let {
+ setSelection(options.indexOf(it.label), false)
+ }
+
+ onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ when (position) {
+ 0 -> listener.onOverrideSelected(option = null)
+ else -> listener.onOverrideSelected(feature.options[position - 1])
+ }
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {
+ // do nothing
+ }
+ }
+ }
+ }
+
+ fun interface Listener {
+ fun onOverrideSelected(option: T?)
+ }
+
+ data class OverrideDropdown(
+ val label: String,
+ val options: List,
+ val activeOption: T?,
+ )
+}
+
+interface OverrideOption {
+ val label: String
+}
diff --git a/vector/src/debug/java/im/vector/app/features/debug/settings/PrivateSettingOverrides.kt b/vector/src/debug/java/im/vector/app/features/debug/settings/PrivateSettingOverrides.kt
new file mode 100644
index 0000000000..316e8fb901
--- /dev/null
+++ b/vector/src/debug/java/im/vector/app/features/debug/settings/PrivateSettingOverrides.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.debug.settings
+
+sealed interface BooleanHomeserverCapabilitiesOverride : OverrideOption {
+
+ companion object {
+ fun from(value: Boolean?) = when (value) {
+ null -> null
+ true -> ForceEnabled
+ false -> ForceDisabled
+ }
+ }
+
+ object ForceEnabled : BooleanHomeserverCapabilitiesOverride {
+ override val label = "Force enabled"
+ }
+
+ object ForceDisabled : BooleanHomeserverCapabilitiesOverride {
+ override val label = "Force disabled"
+ }
+}
+
+fun BooleanHomeserverCapabilitiesOverride?.toBoolean() = when (this) {
+ null -> null
+ BooleanHomeserverCapabilitiesOverride.ForceDisabled -> false
+ BooleanHomeserverCapabilitiesOverride.ForceEnabled -> true
+}
diff --git a/vector/src/debug/res/layout/fragment_debug_private_settings.xml b/vector/src/debug/res/layout/fragment_debug_private_settings.xml
index 6760c68169..c42ad68dce 100644
--- a/vector/src/debug/res/layout/fragment_debug_private_settings.xml
+++ b/vector/src/debug/res/layout/fragment_debug_private_settings.xml
@@ -31,6 +31,24 @@
android:layout_height="wrap_content"
android:text="Force login and registration fallback" />
+
+
+
+
diff --git a/vector/src/debug/res/layout/view_boolean_dropdown.xml b/vector/src/debug/res/layout/view_boolean_dropdown.xml
new file mode 100644
index 0000000000..5018d61047
--- /dev/null
+++ b/vector/src/debug/res/layout/view_boolean_dropdown.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml
index 141424e8fc..46679f203c 100644
--- a/vector/src/main/AndroidManifest.xml
+++ b/vector/src/main/AndroidManifest.xml
@@ -53,6 +53,8 @@
+
+
diff --git a/vector/src/main/java/im/vector/app/core/animations/Konfetti.kt b/vector/src/main/java/im/vector/app/core/animations/Konfetti.kt
new file mode 100644
index 0000000000..22764ac5bd
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/animations/Konfetti.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.core.animations
+
+import android.content.Context
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+import im.vector.app.R
+import nl.dionsegijn.konfetti.KonfettiView
+import nl.dionsegijn.konfetti.models.Shape
+import nl.dionsegijn.konfetti.models.Size
+
+fun KonfettiView.play() {
+ val confettiColors = listOf(
+ R.color.palette_azure,
+ R.color.palette_grape,
+ R.color.palette_verde,
+ R.color.palette_polly,
+ R.color.palette_melon,
+ R.color.palette_aqua,
+ R.color.palette_prune,
+ R.color.palette_kiwi
+ )
+ build()
+ .addColors(confettiColors.toColorInt(context))
+ .setDirection(0.0, 359.0)
+ .setSpeed(2f, 5f)
+ .setFadeOutEnabled(true)
+ .setTimeToLive(2000L)
+ .addShapes(Shape.Square, Shape.Circle)
+ .addSizes(Size(12))
+ .setPosition(-50f, width + 50f, -50f, -50f)
+ .streamFor(150, 3000L)
+}
+
+@ColorInt
+private fun List.toColorInt(context: Context) = map { ContextCompat.getColor(context, it) }
diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt
index 66abe1fd16..53f0ab3eed 100644
--- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt
+++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt
@@ -104,6 +104,7 @@ import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseDisplayNameFragm
import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseProfilePictureFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment
+import im.vector.app.features.onboarding.ftueauth.FtueAuthPersonalizationCompleteFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordMailConfirmationFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordSuccessFragment
@@ -493,6 +494,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthChooseProfilePictureFragment::class)
fun bindFtueAuthChooseProfilePictureFragment(fragment: FtueAuthChooseProfilePictureFragment): Fragment
+ @Binds
+ @IntoMap
+ @FragmentKey(FtueAuthPersonalizationCompleteFragment::class)
+ fun bindFtueAuthPersonalizationCompleteFragment(fragment: FtueAuthPersonalizationCompleteFragment): Fragment
+
@Binds
@IntoMap
@FragmentKey(UserListFragment::class)
diff --git a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt
index a5575ef536..fdd6e3c2ba 100644
--- a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt
+++ b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt
@@ -46,6 +46,7 @@ import im.vector.app.features.navigation.Navigator
import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.SharedPrefPinCodeStore
import im.vector.app.features.room.VectorRoomDisplayNameFallbackProvider
+import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.ui.SharedPreferencesUiStateRepository
import im.vector.app.features.ui.UiStateRepository
import kotlinx.coroutines.CoroutineScope
@@ -113,10 +114,14 @@ object VectorStaticModule {
}
@Provides
- fun providesMatrixConfiguration(vectorRoomDisplayNameFallbackProvider: VectorRoomDisplayNameFallbackProvider): MatrixConfiguration {
+ fun providesMatrixConfiguration(
+ vectorPreferences: VectorPreferences,
+ vectorRoomDisplayNameFallbackProvider: VectorRoomDisplayNameFallbackProvider): MatrixConfiguration {
return MatrixConfiguration(
applicationFlavor = BuildConfig.FLAVOR_DESCRIPTION,
- roomDisplayNameFallbackProvider = vectorRoomDisplayNameFallbackProvider
+ roomDisplayNameFallbackProvider = vectorRoomDisplayNameFallbackProvider,
+ threadMessagesEnabledDefault = vectorPreferences.areThreadMessagesEnabled(),
+ presenceSyncEnabled = BuildConfig.PRESENCE_SYNC_ENABLED
)
}
diff --git a/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItemWithPowerLevelWithPresence.kt b/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItemWithPowerLevelWithPresence.kt
index 92216cbb38..923fa80b55 100644
--- a/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItemWithPowerLevelWithPresence.kt
+++ b/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItemWithPowerLevelWithPresence.kt
@@ -25,10 +25,11 @@ import org.matrix.android.sdk.api.session.presence.model.UserPresence
@EpoxyModelClass(layout = R.layout.item_profile_matrix_item)
abstract class ProfileMatrixItemWithPowerLevelWithPresence : ProfileMatrixItemWithPowerLevel() {
+ @EpoxyAttribute var showPresence: Boolean = true
@EpoxyAttribute var userPresence: UserPresence? = null
override fun bind(holder: Holder) {
super.bind(holder)
- holder.presenceImageView.render(userPresence = userPresence)
+ holder.presenceImageView.render(showPresence, userPresence)
}
}
diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
index b1df29ebc6..28c1587b1a 100644
--- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
+++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
@@ -22,7 +22,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
fun TimelineEvent.canReact(): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
- return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START) &&
+ return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START &&
root.sendState == SendState.SYNCED &&
!root.isRedacted()
}
diff --git a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt
index 54fcac42d1..f9ca8cb57c 100644
--- a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt
+++ b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt
@@ -23,6 +23,7 @@ import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import androidx.annotation.AttrRes
+import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
@@ -70,6 +71,15 @@ fun View.setAttributeTintedBackground(@DrawableRes drawableRes: Int, @AttrRes ti
background = drawable
}
+fun View.tintBackground(@ColorInt tintColor: Int) {
+ val bkg = background?.let {
+ val backgroundDrawable = DrawableCompat.wrap(background)
+ DrawableCompat.setTint(backgroundDrawable, tintColor)
+ backgroundDrawable
+ }
+ background = bkg
+}
+
fun ImageView.setAttributeTintedImageResource(@DrawableRes drawableRes: Int, @AttrRes tint: Int) {
val drawable = ContextCompat.getDrawable(context, drawableRes)!!
DrawableCompat.setTint(drawable, ThemeUtils.getColor(context, tint))
diff --git a/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt b/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt
index dabf11b9d3..eada3a4f25 100644
--- a/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt
+++ b/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt
@@ -19,6 +19,7 @@ package im.vector.app.core.utils
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
+import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@@ -32,6 +33,7 @@ import im.vector.app.R
import im.vector.app.core.platform.VectorBaseActivity
// Permissions sets
+val PERMISSIONS_EMPTY = emptyList()
val PERMISSIONS_FOR_AUDIO_IP_CALL = listOf(Manifest.permission.RECORD_AUDIO)
val PERMISSIONS_FOR_VIDEO_IP_CALL = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
val PERMISSIONS_FOR_VOICE_MESSAGE = listOf(Manifest.permission.RECORD_AUDIO)
@@ -40,9 +42,12 @@ val PERMISSIONS_FOR_MEMBERS_SEARCH = listOf(Manifest.permission.READ_CONTACTS)
val PERMISSIONS_FOR_ROOM_AVATAR = listOf(Manifest.permission.CAMERA)
val PERMISSIONS_FOR_WRITING_FILES = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
val PERMISSIONS_FOR_PICKING_CONTACT = listOf(Manifest.permission.READ_CONTACTS)
-val PERMISSIONS_FOR_LOCATION_SHARING = listOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
-
-val PERMISSIONS_EMPTY = emptyList()
+val PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING = listOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
+val PERMISSIONS_FOR_BACKGROUND_LOCATION_SHARING = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
+} else {
+ PERMISSIONS_EMPTY
+}
// This is not ideal to store the value like that, but it works
private var permissionDialogDisplayed = false
@@ -123,6 +128,7 @@ fun checkPermissions(permissionsToBeGranted: List,
.setPositiveButton(R.string.ok) { _, _ ->
activityResultLauncher.launch(missingPermissions.toTypedArray())
}
+ .setNegativeButton(R.string.action_not_now, null)
.show()
} else {
// some permissions are not granted, ask permissions
diff --git a/vector/src/main/java/im/vector/app/features/DefaultVectorOverrides.kt b/vector/src/main/java/im/vector/app/features/DefaultVectorOverrides.kt
index 4128fdbe3c..daa0d9e0bd 100644
--- a/vector/src/main/java/im/vector/app/features/DefaultVectorOverrides.kt
+++ b/vector/src/main/java/im/vector/app/features/DefaultVectorOverrides.kt
@@ -22,9 +22,16 @@ import kotlinx.coroutines.flow.flowOf
interface VectorOverrides {
val forceDialPad: Flow
val forceLoginFallback: Flow
+ val forceHomeserverCapabilities: Flow?
}
+data class HomeserverCapabilitiesOverride(
+ val canChangeDisplayName: Boolean?,
+ val canChangeAvatar: Boolean?
+)
+
class DefaultVectorOverrides : VectorOverrides {
override val forceDialPad = flowOf(false)
override val forceLoginFallback = flowOf(false)
+ override val forceHomeserverCapabilities: Flow? = null
}
diff --git a/vector/src/main/java/im/vector/app/features/MainActivity.kt b/vector/src/main/java/im/vector/app/features/MainActivity.kt
index 33b735551c..42bd2318b3 100644
--- a/vector/src/main/java/im/vector/app/features/MainActivity.kt
+++ b/vector/src/main/java/im/vector/app/features/MainActivity.kt
@@ -241,7 +241,7 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity
// We have a session.
// Check it can be opened
if (sessionHolder.getActiveSession().isOpenable) {
- HomeActivity.newIntent(this)
+ HomeActivity.newIntent(this, existingSession = true)
} else {
// The token is still invalid
navigator.softLogout(this)
diff --git a/vector/src/main/java/im/vector/app/features/analytics/extensions/ComposerExt.kt b/vector/src/main/java/im/vector/app/features/analytics/extensions/ComposerExt.kt
new file mode 100644
index 0000000000..407fca2d5b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/analytics/extensions/ComposerExt.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.analytics.extensions
+
+import im.vector.app.features.analytics.plan.Composer
+import im.vector.app.features.home.room.detail.composer.MessageComposerViewState
+import im.vector.app.features.home.room.detail.composer.SendMode
+
+fun MessageComposerViewState.toAnalyticsComposer(): Composer =
+ Composer(
+ inThread = isInThreadTimeline(),
+ isEditing = sendMode is SendMode.Edit,
+ isReply = sendMode is SendMode.Reply,
+ startsThread = startsThread
+ )
diff --git a/vector/src/main/java/im/vector/app/features/analytics/extensions/InteractionExt.kt b/vector/src/main/java/im/vector/app/features/analytics/extensions/InteractionExt.kt
new file mode 100644
index 0000000000..3ed9c419ee
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/analytics/extensions/InteractionExt.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.analytics.extensions
+
+import im.vector.app.features.analytics.plan.Interaction
+
+fun Interaction.Name.toAnalyticsInteraction(interactionType: Interaction.InteractionType = Interaction.InteractionType.Touch) =
+ Interaction(
+ name = this,
+ interactionType = interactionType
+ )
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Composer.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/Composer.kt
index a3b847a1bd..79be8aae2b 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/Composer.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/Composer.kt
@@ -39,6 +39,10 @@ data class Composer(
* sent event.
*/
val isReply: Boolean,
+ /**
+ * Whether this message begins a new thread or not.
+ */
+ val startsThread: Boolean? = null,
) : VectorAnalyticsEvent {
override fun getName() = "Composer"
@@ -48,6 +52,7 @@ data class Composer(
put("inThread", inThread)
put("isEditing", isEditing)
put("isReply", isReply)
+ startsThread?.let { put("startsThread", it) }
}.takeIf { it.isNotEmpty() }
}
}
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt
index 4c00cfd014..2007f75fbc 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt
@@ -50,6 +50,26 @@ data class Interaction(
*/
MobileRoomLeave,
+ /**
+ * User tapped on Threads button on Room screen.
+ */
+ MobileRoomThreadListButton,
+
+ /**
+ * User tapped on a thread summary item on Room screen.
+ */
+ MobileRoomThreadSummaryItem,
+
+ /**
+ * User tapped on the filter button on ThreadList screen.
+ */
+ MobileThreadListFilterItem,
+
+ /**
+ * User selected a thread on ThreadList screen.
+ */
+ MobileThreadListThreadItem,
+
/**
* User tapped the already selected space from the space list.
*/
@@ -115,12 +135,24 @@ data class Interaction(
*/
WebRightPanelRoomUserInfoInviteButton,
+ /**
+ * User clicked the threads 'show' filter dropdown in the threads panel
+ * in Element Web/Desktop.
+ */
+ WebRightPanelThreadPanelFilterDropdown,
+
/**
* User clicked the create room button in the room directory of Element
* Web/Desktop.
*/
WebRoomDirectoryCreateRoomButton,
+ /**
+ * User clicked the Threads button in the top right of a room in Element
+ * Web/Desktop.
+ */
+ WebRoomHeaderButtonsThreadsButton,
+
/**
* User adjusted their favourites using the context menu on the header
* of a room in Element Web/Desktop.
@@ -207,7 +239,7 @@ data class Interaction(
/**
* User clicked the explore rooms button in the + context menu of the
- * rooms sublist in Element Web/Desktop.
+ * rooms sublist in Element Web/Desktop.
*/
WebRoomListRoomsSublistPlusMenuExploreRoomsItem,
@@ -223,6 +255,12 @@ data class Interaction(
*/
WebRoomSettingsSecurityTabCreateNewRoomButton,
+ /**
+ * User clicked a thread summary in the timeline of a room in Element
+ * Web/Desktop.
+ */
+ WebRoomTimelineThreadSummaryButton,
+
/**
* User interacted with the theme radio selector in the Appearance tab
* of Settings in Element Web/Desktop.
@@ -259,6 +297,17 @@ data class Interaction(
*/
WebSpaceHomeCreateRoomButton,
+ /**
+ * User clicked the back button on a Thread view going back to the
+ * Threads Panel of Element Web/Desktop.
+ */
+ WebThreadViewBackButton,
+
+ /**
+ * User selected a thread in the Threads panel in Element Web/Desktop.
+ */
+ WebThreadsPanelThreadItem,
+
/**
* User clicked the theme toggle button in the user menu of Element
* Web/Desktop.
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt
index 29b6667cca..79bae544ec 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt
@@ -286,6 +286,11 @@ data class MobileScreen(
*/
SwitchDirectory,
+ /**
+ * Screen that displays list of threads for a room
+ */
+ ThreadList,
+
/**
* A screen that shows information about a room member.
*/
diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt
index 87928cb3db..6f3130ab23 100644
--- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt
+++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt
@@ -37,7 +37,7 @@ import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.utils.PERMISSIONS_EMPTY
-import im.vector.app.core.utils.PERMISSIONS_FOR_LOCATION_SHARING
+import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
import im.vector.app.core.utils.PERMISSIONS_FOR_PICKING_CONTACT
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.databinding.ViewAttachmentTypeSelectorBinding
@@ -219,6 +219,6 @@ class AttachmentTypeSelectorView(context: Context,
STICKER(PERMISSIONS_EMPTY, R.string.tooltip_attachment_sticker),
CONTACT(PERMISSIONS_FOR_PICKING_CONTACT, R.string.tooltip_attachment_contact),
POLL(PERMISSIONS_EMPTY, R.string.tooltip_attachment_poll),
- LOCATION(PERMISSIONS_FOR_LOCATION_SHARING, R.string.tooltip_attachment_location)
+ LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, R.string.tooltip_attachment_location)
}
}
diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt
index 28a27a01a1..daeeb0c700 100644
--- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt
+++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt
@@ -95,6 +95,7 @@ import javax.inject.Inject
data class HomeActivityArgs(
val clearNotification: Boolean,
val accountCreation: Boolean,
+ val hasExistingSession: Boolean = false,
val inviteNotificationRoomId: String? = null
) : Parcelable
@@ -258,6 +259,8 @@ class HomeActivity :
HomeActivityViewEvents.PromptToEnableSessionPush -> handlePromptToEnablePush()
is HomeActivityViewEvents.OnCrossSignedInvalidated -> handleCrossSigningInvalidated(it)
HomeActivityViewEvents.ShowAnalyticsOptIn -> handleShowAnalyticsOptIn()
+ HomeActivityViewEvents.NotifyUserForThreadsMigration -> handleNotifyUserForThreadsMigration()
+ is HomeActivityViewEvents.MigrateThreads -> migrateThreadsIfNeeded(it.checkSession)
}.exhaustive
}
homeActivityViewModel.onEach { renderState(it) }
@@ -274,6 +277,48 @@ class HomeActivity :
navigator.openAnalyticsOptIn(this)
}
+ /**
+ * Migrating from old threads io.element.thread to new m.thread needs an initial sync to
+ * sync and display existing messages appropriately
+ */
+ private fun migrateThreadsIfNeeded(checkSession: Boolean) {
+ if (checkSession) {
+ // We should check session to ensure we will only clear cache if needed
+ val args = intent.getParcelableExtra(Mavericks.KEY_ARG)
+ if (args?.hasExistingSession == true) {
+ // existingSession --> Will be true only if we came from an existing active session
+ Timber.i("----> Migrating threads from an existing session..")
+ handleThreadsMigration()
+ } else {
+ // We came from a new session and not an existing one,
+ // so there is no need to migrate threads while an initial synced performed
+ Timber.i("----> No thread migration needed, we are ok")
+ vectorPreferences.setShouldMigrateThreads(shouldMigrate = false)
+ }
+ } else {
+ // Proceed with migration
+ handleThreadsMigration()
+ }
+ }
+
+ /**
+ * Clear cache and restart to invoke an initial sync for threads migration
+ */
+ private fun handleThreadsMigration() {
+ Timber.i("----> Threads Migration detected, clearing cache and sync...")
+ vectorPreferences.setShouldMigrateThreads(shouldMigrate = false)
+ MainActivity.restartApp(this, MainActivityArgs(clearCache = true))
+ }
+
+ private fun handleNotifyUserForThreadsMigration() {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.threads_notice_migration_title)
+ .setMessage(R.string.threads_notice_migration_message)
+ .setCancelable(true)
+ .setPositiveButton(R.string.sas_got_it) { _, _ -> }
+ .show()
+ }
+
private fun handleIntent(intent: Intent?) {
intent?.dataString?.let { deepLink ->
val resolvedLink = when {
@@ -563,11 +608,13 @@ class HomeActivity :
fun newIntent(context: Context,
clearNotification: Boolean = false,
accountCreation: Boolean = false,
+ existingSession: Boolean = false,
inviteNotificationRoomId: String? = null
): Intent {
val args = HomeActivityArgs(
clearNotification = clearNotification,
accountCreation = accountCreation,
+ hasExistingSession = existingSession,
inviteNotificationRoomId = inviteNotificationRoomId
)
diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt
index adc44a57bd..5efd49a579 100644
--- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt
@@ -25,4 +25,6 @@ sealed interface HomeActivityViewEvents : VectorViewEvents {
data class OnCrossSignedInvalidated(val userItem: MatrixItem.UserItem) : HomeActivityViewEvents
object PromptToEnableSessionPush : HomeActivityViewEvents
object ShowAnalyticsOptIn : HomeActivityViewEvents
+ object NotifyUserForThreadsMigration : HomeActivityViewEvents
+ data class MigrateThreads(val checkSession: Boolean) : HomeActivityViewEvents
}
diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt
index 35c112b63a..b4af50c7ff 100644
--- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt
@@ -51,6 +51,7 @@ import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.MXUsersDevicesMap
+import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.util.awaitCallback
import timber.log.Timber
import kotlin.coroutines.Continuation
@@ -62,6 +63,7 @@ class HomeActivityViewModel @AssistedInject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val reAuthHelper: ReAuthHelper,
private val analyticsStore: AnalyticsStore,
+ private val lightweightSettingsStorage: LightweightSettingsStorage,
private val vectorPreferences: VectorPreferences
) : VectorViewModel(initialState) {
@@ -84,6 +86,7 @@ class HomeActivityViewModel @AssistedInject constructor(
checkSessionPushIsOn()
observeCrossSigningReset()
observeAnalytics()
+ initThreadsMigration()
}
private fun observeAnalytics() {
@@ -130,6 +133,46 @@ class HomeActivityViewModel @AssistedInject constructor(
.launchIn(viewModelScope)
}
+ /**
+ * Handle threads migration. The migration includes:
+ * - Notify users that had io.element.thread enabled from labs
+ * - Re-Enable m.thread to those users (that they had enabled labs threads)
+ * - Handle migration when threads are enabled by default
+ */
+ private fun initThreadsMigration() {
+ // When we would like to enable threads for all users
+// if(vectorPreferences.shouldMigrateThreads()) {
+// vectorPreferences.setThreadMessagesEnabled()
+// lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled())
+// }
+
+ when {
+ // Notify users
+ vectorPreferences.shouldNotifyUserAboutThreads() && vectorPreferences.areThreadMessagesEnabled() -> {
+ Timber.i("----> Notify users about threads")
+ // Notify the user if needed that we migrated to support m.thread
+ // instead of io.element.thread so old thread messages will be displayed as normal timeline messages
+ _viewEvents.post(HomeActivityViewEvents.NotifyUserForThreadsMigration)
+ vectorPreferences.userNotifiedAboutThreads()
+ }
+ // Migrate users with enabled lab settings
+ vectorPreferences.shouldNotifyUserAboutThreads() && vectorPreferences.shouldMigrateThreads() -> {
+ Timber.i("----> Migrate threads with enabled labs")
+ // If user had io.element.thread enabled then enable the new thread support,
+ // clear cache to sync messages appropriately
+ vectorPreferences.setThreadMessagesEnabled()
+ lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled())
+ // Clear Cache
+ _viewEvents.post(HomeActivityViewEvents.MigrateThreads(checkSession = false))
+ }
+ // Enable all users
+ vectorPreferences.shouldMigrateThreads() && vectorPreferences.areThreadMessagesEnabled() -> {
+ Timber.i("----> Try to migrate threads")
+ _viewEvents.post(HomeActivityViewEvents.MigrateThreads(checkSession = true))
+ }
+ }
+ }
+
private fun observeInitialSync() {
val session = activeSessionHolder.getSafeActiveSession() ?: return
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
index 53a23842aa..13675f4866 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
@@ -21,7 +21,6 @@ import android.app.Activity
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
-import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
@@ -73,6 +72,7 @@ import de.spiritcroc.matrixsdk.util.DbgUtil
import de.spiritcroc.matrixsdk.util.Dimber
import de.spiritcroc.recyclerview.widget.BetterLinearLayoutManager
import im.vector.app.R
+import im.vector.app.core.animations.play
import im.vector.app.core.dialogs.ConfirmationDialogBuilder
import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper
import im.vector.app.core.epoxy.LayoutManagerStateRestorer
@@ -123,7 +123,8 @@ import im.vector.app.core.utils.startInstallFromSourceIntent
import im.vector.app.core.utils.toast
import im.vector.app.databinding.DialogReportContentBinding
import im.vector.app.databinding.FragmentTimelineBinding
-import im.vector.app.features.analytics.plan.Composer
+import im.vector.app.features.analytics.extensions.toAnalyticsInteraction
+import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.attachments.AttachmentTypeSelectorView
import im.vector.app.features.attachments.AttachmentsHelper
@@ -182,6 +183,7 @@ import im.vector.app.features.html.PillsPostProcessor
import im.vector.app.features.invite.VectorInviteView
import im.vector.app.features.location.LocationSharingMode
import im.vector.app.features.location.toLocationData
+import im.vector.app.features.media.AttachmentData
import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.notifications.NotificationDrawerManager
@@ -210,10 +212,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import nl.dionsegijn.konfetti.models.Shape
-import nl.dionsegijn.konfetti.models.Size
import org.billcarsonfr.jsonviewer.JSonViewerDialog
import org.commonmark.parser.Parser
+import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.EventType
@@ -270,7 +271,8 @@ class TimelineFragment @Inject constructor(
private val callManager: WebRtcCallManager,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val typingHelper: TypingHelper,
- private val clock: Clock
+ private val clock: Clock,
+ private val matrixConfiguration: MatrixConfiguration
) :
VectorBaseFragment(),
TimelineEventController.Callback,
@@ -584,16 +586,7 @@ class TimelineFragment @Inject constructor(
when (chatEffect) {
ChatEffect.CONFETTI -> {
views.viewKonfetti.isVisible = true
- views.viewKonfetti.build()
- .addColors(Color.YELLOW, Color.GREEN, Color.MAGENTA)
- .setDirection(0.0, 359.0)
- .setSpeed(2f, 5f)
- .setFadeOutEnabled(true)
- .setTimeToLive(2000L)
- .addShapes(Shape.Square, Shape.Circle)
- .addSizes(Size(12))
- .setPosition(-50f, views.viewKonfetti.width + 50f, -50f, -50f)
- .streamFor(150, 3000L)
+ views.viewKonfetti.play()
}
ChatEffect.SNOWFALL -> {
views.viewSnowFall.isVisible = true
@@ -1271,7 +1264,7 @@ class TimelineFragment @Inject constructor(
val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong())
getString(R.string.voice_message_reply_content, formattedDuration)
} else if (messageContent is MessagePollContent) {
- messageContent.pollCreationInfo?.question?.question
+ messageContent.getBestPollCreationInfo()?.question?.getBestQuestion()
} else {
messageContent?.body ?: ""
}
@@ -1600,9 +1593,6 @@ class TimelineFragment @Inject constructor(
return
}
if (text.isNotBlank()) {
- withState(messageComposerViewModel) { state ->
- analyticsTracker.capture(Composer(isThreadTimeLine(), isEditing = state.sendMode is SendMode.Edit, isReply = state.sendMode is SendMode.Reply))
- }
// We collapse ASAP, if not there will be a slight annoying delay
views.composerLayout.collapse(true)
lockSendButton = true
@@ -1748,8 +1738,11 @@ class TimelineFragment @Inject constructor(
views.includeRoomToolbar.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN
views.includeRoomToolbar.roomToolbarTitleView.text = roomSummary.displayName
avatarRenderer.render(roomSummary.toMatrixItem(), views.includeRoomToolbar.roomToolbarAvatarImageView)
- views.includeRoomToolbar.roomToolbarDecorationImageView.render(roomSummary.roomEncryptionTrustLevel)
- views.includeRoomToolbar.roomToolbarPresenceImageView.render(roomSummary.isDirect, roomSummary.directUserPresence)
+ val showPresence = roomSummary.isDirect && matrixConfiguration.presenceSyncEnabled
+ views.includeRoomToolbar.roomToolbarPresenceImageView.render(showPresence, roomSummary.directUserPresence)
+ //val shieldView = if (showPresence) views.includeRoomToolbar.roomToolbarTitleShield else views.includeRoomToolbar.roomToolbarAvatarShield
+ val shieldView = views.includeRoomToolbar.roomToolbarDecorationImageView
+ shieldView.render(roomSummary.roomEncryptionTrustLevel)
views.includeRoomToolbar.roomToolbarPublicImageView.isVisible = roomSummary.isPublic && !roomSummary.isDirect
}
} else {
@@ -1923,13 +1916,11 @@ class TimelineFragment @Inject constructor(
}
is RoomDetailAction.ResumeVerification -> {
val otherUserId = data.otherUserId ?: return
- VerificationBottomSheet().apply {
- setArguments(VerificationBottomSheet.VerificationArgs(
- otherUserId = otherUserId,
- verificationId = data.transactionId,
- roomId = timelineArgs.roomId
- ))
- }.show(parentFragmentManager, "REQ")
+ VerificationBottomSheet.withArgs(
+ roomId = timelineArgs.roomId,
+ otherUserId = otherUserId,
+ transactionId = data.transactionId,
+ ).show(parentFragmentManager, "REQ")
}
}
}
@@ -2020,12 +2011,16 @@ class TimelineFragment @Inject constructor(
vectorBaseActivity.notImplemented("encrypted message click")
}
- override fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View) {
+ override fun onImageMessageClicked(messageImageContent: MessageImageInfoContent,
+ mediaData: ImageContentRenderer.Data,
+ view: View,
+ inMemory: List) {
navigator.openMediaViewer(
activity = requireActivity(),
roomId = timelineArgs.roomId,
mediaData = mediaData,
- view = view
+ view = view,
+ inMemory = inMemory
) { pairs ->
pairs.add(Pair(views.roomToolbar, ViewCompat.getTransitionName(views.roomToolbar) ?: ""))
pairs.add(Pair(views.composerLayout, ViewCompat.getTransitionName(views.composerLayout) ?: ""))
@@ -2306,7 +2301,7 @@ class TimelineFragment @Inject constructor(
timelineViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
}
is EventSharedAction.Edit -> {
- if (action.eventType == EventType.POLL_START) {
+ if (action.eventType in EventType.POLL_START) {
navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT)
} else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
@@ -2326,7 +2321,7 @@ class TimelineFragment @Inject constructor(
}
is EventSharedAction.ReplyInThread -> {
if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
- navigateToThreadTimeline(action.eventId)
+ navigateToThreadTimeline(action.eventId, action.startsThread)
} else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
}
@@ -2486,9 +2481,11 @@ class TimelineFragment @Inject constructor(
* using the ThreadsActivity
*/
- private fun navigateToThreadTimeline(rootThreadEventId: String) {
+ private fun navigateToThreadTimeline(rootThreadEventId: String, startsThread: Boolean = false) {
+ analyticsTracker.capture(Interaction.Name.MobileRoomThreadSummaryItem.toAnalyticsInteraction())
context?.let {
val roomThreadDetailArgs = ThreadTimelineArgs(
+ startsThread = startsThread,
roomId = timelineArgs.roomId,
displayName = timelineViewModel.getRoomSummary()?.displayName,
avatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl,
@@ -2504,6 +2501,7 @@ class TimelineFragment @Inject constructor(
*/
private fun navigateToThreadList() {
+ analyticsTracker.capture(Interaction.Name.MobileRoomThreadListButton.toAnalyticsInteraction())
context?.let {
val roomThreadDetailArgs = ThreadTimelineArgs(
roomId = timelineArgs.roomId,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
index b37a0f08e7..af334b54bf 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
@@ -176,6 +176,9 @@ class TimelineViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() {
const val PAGINATION_COUNT = 50
+
+ // The larger the number the faster the results, COUNT=200 for 500 thread messages its x4 faster than COUNT=50
+ const val PAGINATION_COUNT_THREADS_PERMALINK = 200
}
init {
@@ -534,7 +537,10 @@ class TimelineViewModel @AssistedInject constructor(
private fun handleSendSticker(action: RoomDetailAction.SendSticker) {
val content = initialState.rootThreadEventId?.let {
- action.stickerContent.copy(relatesTo = RelationDefaultContent(RelationType.IO_THREAD, it))
+ action.stickerContent.copy(relatesTo = RelationDefaultContent(
+ type = RelationType.THREAD,
+ isFallingBack = true,
+ eventId = it))
} ?: action.stickerContent
room.sendEvent(EventType.STICKER, content.toContent())
@@ -1235,11 +1241,31 @@ class TimelineViewModel @AssistedInject constructor(
}
}
+ /**
+ * Navigates to the appropriate event (by paginating the thread timeline until the event is found
+ * in the snapshot. The main reason for this function is to support the /relations api
+ */
+ private var threadPermalinkHandled = false
+ private fun navigateToThreadEventIfNeeded(snapshot: List) {
+ if (eventId != null && initialState.rootThreadEventId != null) {
+ // When we have a permalink and we are in a thread timeline
+ if (snapshot.firstOrNull { it.eventId == eventId } != null && !threadPermalinkHandled) {
+ // Permalink event found lets navigate there
+ handleNavigateToEvent(RoomDetailAction.NavigateToEvent(eventId, true))
+ threadPermalinkHandled = true
+ } else {
+ // Permalink event not found yet continue paginating
+ timeline.paginate(Timeline.Direction.BACKWARDS, PAGINATION_COUNT_THREADS_PERMALINK)
+ }
+ }
+ }
+
override fun onTimelineUpdated(snapshot: List) {
rmDimber.i{"onTimelineUpdated"}
viewModelScope.launch {
// tryEmit doesn't work with SharedFlow without cache
timelineEvents.emit(snapshot)
+ navigateToThreadEventIfNeeded(snapshot)
}
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt
index 21e3b25deb..fd6126583f 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt
@@ -27,6 +27,7 @@ import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.analytics.AnalyticsTracker
+import im.vector.app.features.analytics.extensions.toAnalyticsComposer
import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom
import im.vector.app.features.attachments.toContentAttachmentData
import im.vector.app.features.command.CommandParser
@@ -190,6 +191,9 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun handleSendMessage(action: MessageComposerAction.SendMessage) {
withState { state ->
+ analyticsTracker.capture(state.toAnalyticsComposer()).also {
+ setState { copy(startsThread = false) }
+ }
when (state.sendMode) {
is SendMode.Regular -> {
when (val slashCommandResult = commandParser.parseSlashCommand(
@@ -467,7 +471,8 @@ class MessageComposerViewModel @AssistedInject constructor(
// is original event a reply?
val relationContent = state.sendMode.timelineEvent.getRelationContent()
val inReplyTo = if (state.rootThreadEventId != null) {
- if (relationContent?.inReplyTo?.shouldRenderInThread() == true) {
+ // Thread event
+ if (relationContent?.shouldRenderInThread() == true) {
// Reply within a thread event
relationContent.inReplyTo?.eventId
} else {
@@ -511,6 +516,7 @@ class MessageComposerViewModel @AssistedInject constructor(
is SendMode.Reply -> {
val timelineEvent = state.sendMode.timelineEvent
val showInThread = state.sendMode.timelineEvent.root.isThread() && state.rootThreadEventId == null
+ // If threads are disabled this will make the fallback replies visible to clients with threads enabled
val rootThreadEventId = if (showInThread) timelineEvent.root.getRootThreadEventId() else null
state.rootThreadEventId?.let {
room.replyInThread(
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt
index 585b2fbb46..9a5d8b720b 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt
@@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
+import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
@@ -63,6 +64,7 @@ data class MessageComposerViewState(
val isSendButtonActive: Boolean = false,
val isSendButtonVisible: Boolean = false,
val rootThreadEventId: String? = null,
+ val startsThread: Boolean = false,
val sendMode: SendMode = SendMode.Regular("", false),
val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle
) : MavericksState {
@@ -81,6 +83,7 @@ data class MessageComposerViewState(
constructor(args: TimelineArgs) : this(
roomId = args.roomId,
+ startsThread = args.threadTimelineArgs?.startsThread.orFalse(),
rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId)
fun isInThreadTimeline(): Boolean = rootThreadEventId != null
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt
index 2cdc1a0d90..5b1f17cfe2 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt
@@ -32,6 +32,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.core.ui.list.GenericHeaderItem_
import im.vector.app.features.home.AvatarRenderer
+import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.Content
@@ -45,6 +46,7 @@ class SearchResultController @Inject constructor(
private val avatarRenderer: AvatarRenderer,
private val stringProvider: StringProvider,
private val dateFormatter: VectorDateFormatter,
+ private val displayableEventFormatter: DisplayableEventFormatter,
private val userPreferencesProvider: UserPreferencesProvider
) : TypedEpoxyController() {
@@ -125,6 +127,7 @@ class SearchResultController @Inject constructor(
.sender(eventAndSender.sender
?: eventAndSender.event.senderId?.let { session.getRoomMember(it, data.roomId) }?.toMatrixItem())
.threadDetails(event.threadDetails)
+ .threadSummaryFormatted(displayableEventFormatter.formatThreadSummary(event.threadDetails?.threadSummaryLatestEvent).toString())
.areThreadMessagesEnabled(userPreferencesProvider.areThreadMessagesEnabled())
.listener { listener?.onItemClicked(eventAndSender.event) }
.let { result.add(it) }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt
index 2ec786fab2..3e141ab0e9 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt
@@ -42,6 +42,7 @@ abstract class SearchResultItem : VectorEpoxyModel() {
@EpoxyAttribute lateinit var spannable: EpoxyCharSequence
@EpoxyAttribute var sender: MatrixItem? = null
@EpoxyAttribute var threadDetails: ThreadDetails? = null
+ @EpoxyAttribute var threadSummaryFormatted: String? = null
@EpoxyAttribute var areThreadMessagesEnabled: Boolean = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null
@@ -60,8 +61,7 @@ abstract class SearchResultItem : VectorEpoxyModel() {
if (it.isRootThread) {
showThreadSummary(holder)
holder.threadSummaryCounterTextView.text = it.numberOfThreads.toString()
- holder.threadSummaryInfoTextView.text = it.threadSummaryLatestTextMessage.orEmpty()
-
+ holder.threadSummaryInfoTextView.text = threadSummaryFormatted.orEmpty()
val userId = it.threadSummarySenderInfo?.userId ?: return@let
val displayName = it.threadSummarySenderInfo?.displayName
val avatarUrl = it.threadSummarySenderInfo?.avatarUrl
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
index b73b88cc71..ea9be96f46 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
@@ -59,6 +59,7 @@ import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEve
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
+import im.vector.app.features.media.AttachmentData
import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences
@@ -132,7 +133,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
fun onEventVisible(event: TimelineEvent)
fun onRoomCreateLinkClicked(url: String)
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
- fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View)
+ fun onImageMessageClicked(messageImageContent: MessageImageInfoContent,
+ mediaData: ImageContentRenderer.Data,
+ view: View,
+ inMemory: List)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
// fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt
index 048a4754f5..5f12c2f174 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt
@@ -48,7 +48,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int,
data class Reply(val eventId: String) :
EventSharedAction(R.string.reply, R.drawable.ic_reply)
- data class ReplyInThread(val eventId: String) :
+ data class ReplyInThread(val eventId: String, val startsThread: Boolean) :
EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread)
object ViewInRoom :
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
index ded131d552..3940a090e6 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
@@ -61,6 +61,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited
import org.matrix.android.sdk.api.session.room.timeline.isPoll
+import org.matrix.android.sdk.api.session.room.timeline.isRootThread
import org.matrix.android.sdk.api.session.room.timeline.isSticker
import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.unwrap
@@ -181,7 +182,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
} else {
when (timelineEvent.root.getClearType()) {
EventType.MESSAGE,
- EventType.STICKER -> {
+ EventType.STICKER -> {
val messageContent: MessageContent? = timelineEvent.getLastMessageContent()
if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) {
val html = messageContent.formattedBody
@@ -207,13 +208,14 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
EventType.CALL_INVITE,
EventType.CALL_CANDIDATES,
EventType.CALL_HANGUP,
- EventType.CALL_ANSWER -> {
+ EventType.CALL_ANSWER -> {
noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse())
}
- EventType.POLL_START -> {
- timelineEvent.root.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question ?: ""
+ in EventType.POLL_START -> {
+ timelineEvent.root.getClearContent().toModel(catchError = true)
+ ?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: ""
}
- else -> null
+ else -> null
}
}
} catch (failure: Throwable) {
@@ -328,7 +330,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) {
- add(EventSharedAction.ReplyInThread(eventId))
+ add(EventSharedAction.ReplyInThread(eventId, !timelineEvent.isRootThread()))
}
if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) {
@@ -373,7 +375,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
if (canRedact(timelineEvent, actionPermissions)) {
- if (timelineEvent.root.getClearType() == EventType.POLL_START) {
+ if (timelineEvent.root.getClearType() in EventType.POLL_START) {
add(EventSharedAction.Redact(
eventId,
askForReason = informationData.senderId != session.myUserId,
@@ -425,7 +427,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
private fun canReply(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean {
// Only EventType.MESSAGE, EventType.STICKER and EventType.POLL_START event types are supported for the moment
- if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START)) return false
+ if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false
if (!actionPermissions.canSendMessage) return false
return when (messageContent?.msgType) {
@@ -513,7 +515,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
private fun canRedact(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
- if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START)) return false
+ if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false
// Message sent by the current user can always be redacted
if (event.root.senderId == session.myUserId) return true
// Check permission for messages sent by other users
@@ -528,13 +530,13 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
private fun canViewReactions(event: TimelineEvent): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
- if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START)) return false
+ if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false
return event.annotations?.reactionsSummary?.isNotEmpty() ?: false
}
private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE and EventType.POLL_START are supported for the moment
- if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.POLL_START)) return false
+ if (event.root.getClearType() !in listOf(EventType.MESSAGE) + EventType.POLL_START) return false
if (!actionPermissions.canSendMessage) return false
// TODO if user is admin or moderator
val messageContent = event.root.getClearContent().toModel()
@@ -580,13 +582,13 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
private fun canEndPoll(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {
- return event.root.getClearType() == EventType.POLL_START &&
+ return event.root.getClearType() in EventType.POLL_START &&
canRedact(event, actionPermissions) &&
event.annotations?.pollResponseSummary?.closedTime == null
}
private fun canEditPoll(event: TimelineEvent): Boolean {
- return event.root.getClearType() == EventType.POLL_START &&
+ return event.root.getClearType() in EventType.POLL_START &&
event.annotations?.pollResponseSummary?.closedTime == null &&
event.annotations?.pollResponseSummary?.aggregatedContent?.totalVotes ?: 0 == 0
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
index 0cd06886e7..60ef7e8643 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
@@ -156,7 +156,7 @@ class MessageItemFactory @Inject constructor(
if (event.root.isRedacted()) {
// message is redacted
- val attributes = messageItemAttributesFactory.create(null, informationData, callback, params.reactionsSummaryEvents)
+ val attributes = messageItemAttributesFactory.create(null, informationData, callback, params.reactionsSummaryEvents, threadDetails)
return buildRedactedItem(attributes, highlight)
}
@@ -248,7 +248,7 @@ class MessageItemFactory @Inject constructor(
val didUserVoted = pollResponseSummary?.myVote?.isNotEmpty().orFalse()
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val isPollSent = informationData.sendState.isSent()
- val isPollUndisclosed = pollContent.pollCreationInfo?.kind == PollType.UNDISCLOSED
+ val isPollUndisclosed = pollContent.getBestPollCreationInfo()?.kind == PollType.UNDISCLOSED_UNSTABLE
val totalVotesText = (pollResponseSummary?.totalVotes ?: 0).let {
when {
@@ -263,13 +263,13 @@ class MessageItemFactory @Inject constructor(
}
}
- pollContent.pollCreationInfo?.answers?.forEach { option ->
+ pollContent.getBestPollCreationInfo()?.answers?.forEach { option ->
val voteSummary = pollResponseSummary?.votes?.get(option.id)
val isMyVote = pollResponseSummary?.myVote == option.id
val voteCount = voteSummary?.total ?: 0
val votePercentage = voteSummary?.percentage ?: 0.0
val optionId = option.id ?: ""
- val optionAnswer = option.answer ?: ""
+ val optionAnswer = option.getBestAnswer() ?: ""
optionViewStates.add(
if (!isPollSent) {
@@ -292,7 +292,7 @@ class MessageItemFactory @Inject constructor(
)
}
- val question = pollContent.pollCreationInfo?.question?.question ?: ""
+ val question = pollContent.getBestPollCreationInfo()?.question?.getBestQuestion() ?: ""
return PollItem_()
.attributes(attributes)
@@ -482,9 +482,12 @@ class MessageItemFactory @Inject constructor(
.apply {
if (messageContent.msgType == MessageType.MSGTYPE_STICKER_LOCAL) {
mode(ImageContentRenderer.Mode.STICKER)
+ clickListener { view ->
+ callback?.onImageMessageClicked(messageContent, data, view, listOf(data))
+ }
} else {
clickListener { view ->
- callback?.onImageMessageClicked(messageContent, data, view)
+ callback?.onImageMessageClicked(messageContent, data, view, emptyList())
}
}
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt
index fbb57d8f1e..d85cad706d 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt
@@ -40,8 +40,7 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av
.map {
ReadReceiptData(it.roomMember.userId, it.roomMember.avatarUrl, it.roomMember.displayName, it.originServerTs)
}
- .toList()
-
+ .sortedByDescending { it.timestamp }
return ReadReceiptsItem_()
.id("read_receipts_$eventId")
.eventId(eventId)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
index b41e1d8f25..f9d2613e27 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
@@ -94,7 +94,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
when (event.root.getClearType()) {
// Message itemsX
EventType.STICKER,
- EventType.POLL_START,
+ in EventType.POLL_START,
EventType.MESSAGE -> messageItemFactory.create(params)
EventType.REDACTION,
EventType.KEY_VERIFICATION_ACCEPT,
@@ -107,8 +107,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.CALL_SELECT_ANSWER,
EventType.CALL_NEGOTIATE,
EventType.REACTION,
- EventType.POLL_RESPONSE,
- EventType.POLL_END -> noticeItemFactory.create(params)
+ in EventType.POLL_RESPONSE,
+ in EventType.POLL_END -> noticeItemFactory.create(params)
// Calls
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt
index f09b6b827a..8b44bf4c97 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt
@@ -24,9 +24,11 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.features.html.EventHtmlRenderer
import me.gujun.android.span.span
import org.commonmark.node.Document
+import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
+import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
@@ -120,14 +122,14 @@ class DisplayableEventFormatter @Inject constructor(
EventType.CALL_CANDIDATES -> {
span { }
}
- EventType.POLL_START -> {
- timelineEvent.root.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question
+ in EventType.POLL_START -> {
+ timelineEvent.root.getClearContent().toModel(catchError = true)?.getBestPollCreationInfo()?.question?.getBestQuestion()
?: stringProvider.getString(R.string.sent_a_poll)
}
- EventType.POLL_RESPONSE -> {
+ in EventType.POLL_RESPONSE -> {
stringProvider.getString(R.string.poll_response_room_list_preview)
}
- EventType.POLL_END -> {
+ in EventType.POLL_END -> {
stringProvider.getString(R.string.poll_end_room_list_preview)
}
else -> {
@@ -139,6 +141,98 @@ class DisplayableEventFormatter @Inject constructor(
}
}
+ fun formatThreadSummary(
+ event: Event?,
+ latestEdition: String? = null): CharSequence {
+ event ?: return ""
+
+ // There event have been edited
+ if (latestEdition != null) {
+ return run {
+ val localFormattedBody = htmlRenderer.get().parse(latestEdition) as Document
+ val renderedBody = htmlRenderer.get().render(localFormattedBody) ?: latestEdition
+ renderedBody
+ }
+ }
+
+ // The event have been redacted
+ if (event.isRedacted()) {
+ return noticeEventFormatter.formatRedactedEvent(event)
+ }
+
+ // The event is encrypted
+ if (event.isEncrypted() &&
+ event.mxDecryptionResult == null) {
+ return stringProvider.getString(R.string.encrypted_message)
+ }
+
+ return when (event.getClearType()) {
+ EventType.MESSAGE -> {
+ (event.getClearContent().toModel() as? MessageContent)?.let { messageContent ->
+ when (messageContent.msgType) {
+ MessageType.MSGTYPE_TEXT -> {
+ val body = messageContent.getTextDisplayableContent()
+ if (messageContent is MessageTextContent && messageContent.matrixFormattedBody.isNullOrBlank().not()) {
+ val localFormattedBody = htmlRenderer.get().parse(body) as Document
+ val renderedBody = htmlRenderer.get().render(localFormattedBody) ?: body
+ renderedBody
+ } else {
+ body
+ }
+ }
+ MessageType.MSGTYPE_VERIFICATION_REQUEST -> {
+ stringProvider.getString(R.string.verification_request)
+ }
+ MessageType.MSGTYPE_IMAGE -> {
+ stringProvider.getString(R.string.sent_an_image)
+ }
+ MessageType.MSGTYPE_AUDIO -> {
+ if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) {
+ stringProvider.getString(R.string.sent_a_voice_message)
+ } else {
+ stringProvider.getString(R.string.sent_an_audio_file)
+ }
+ }
+ MessageType.MSGTYPE_VIDEO -> {
+ stringProvider.getString(R.string.sent_a_video)
+ }
+ MessageType.MSGTYPE_FILE -> {
+ stringProvider.getString(R.string.sent_a_file)
+ }
+ MessageType.MSGTYPE_LOCATION -> {
+ stringProvider.getString(R.string.sent_location)
+ }
+ else -> {
+ messageContent.body
+ }
+ }
+ } ?: span { }
+ }
+ EventType.STICKER -> {
+ stringProvider.getString(R.string.send_a_sticker)
+ }
+ EventType.REACTION -> {
+ event.getClearContent().toModel()?.relatesTo?.let {
+ emojiSpanify.spanify(stringProvider.getString(R.string.sent_a_reaction, it.key))
+ } ?: span { }
+ }
+ in EventType.POLL_START -> {
+ event.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question
+ ?: stringProvider.getString(R.string.sent_a_poll)
+ }
+ in EventType.POLL_RESPONSE -> {
+ stringProvider.getString(R.string.poll_response_room_list_preview)
+ }
+ in EventType.POLL_END -> {
+ stringProvider.getString(R.string.poll_end_room_list_preview)
+ }
+ else -> {
+ span {
+ }
+ }
+ }
+ }
+
private fun simpleFormat(senderName: String, body: CharSequence, appendAuthor: Boolean): CharSequence {
return if (appendAuthor) {
val forceDirectionChar = if (stringProvider.preferRTL()) "\u200f" else "\u200e"
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
index c7be395693..a20c1e5f97 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
@@ -106,8 +106,8 @@ class NoticeEventFormatter @Inject constructor(
EventType.STATE_SPACE_PARENT,
EventType.REDACTION,
EventType.STICKER,
- EventType.POLL_RESPONSE,
- EventType.POLL_END -> formatDebug(timelineEvent.root)
+ in EventType.POLL_RESPONSE,
+ in EventType.POLL_END -> formatDebug(timelineEvent.root)
else -> {
Timber.v("Type $type not handled by this formatter")
null
@@ -196,8 +196,8 @@ class NoticeEventFormatter @Inject constructor(
}
private fun formatDebug(event: Event): CharSequence {
- val threadPrefix = if (event.isThread()) "thread" else ""
- return "Debug: $threadPrefix event type \"${event.getClearType()}\""
+ val threadPrefix = if (event.isThread()) "thread" else ""
+ return "Debug: $threadPrefix event type \"${event.getClearType()}\""
}
private fun formatRoomCreateEvent(event: Event, isDm: Boolean): CharSequence? {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt
index 0cf30c8c01..7262284c95 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt
@@ -19,7 +19,9 @@ package im.vector.app.features.home.room.detail.timeline.helper
import android.content.Context
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
+import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.DrawableCompat
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import im.vector.app.R
@@ -37,7 +39,8 @@ class LocationPinProvider @Inject constructor(
private val context: Context,
private val activeSessionHolder: ActiveSessionHolder,
private val dimensionConverter: DimensionConverter,
- private val avatarRenderer: AvatarRenderer
+ private val avatarRenderer: AvatarRenderer,
+ private val matrixItemColorProvider: MatrixItemColorProvider
) {
private val cache = mutableMapOf()
@@ -61,35 +64,42 @@ class LocationPinProvider @Inject constructor(
return
}
- activeSessionHolder.getActiveSession().getUser(userId)?.toMatrixItem()?.let {
- val size = dimensionConverter.dpToPx(44)
- avatarRenderer.render(glideRequests, it, object : CustomTarget(size, size) {
- override fun onResourceReady(resource: Drawable, transition: Transition?) {
- Timber.d("## Location: onResourceReady")
- val pinDrawable = createPinDrawable(resource)
- cache[userId] = pinDrawable
- callback(pinDrawable)
- }
+ activeSessionHolder
+ .getActiveSession()
+ .getUser(userId)
+ ?.toMatrixItem()
+ ?.let { userItem ->
+ val size = dimensionConverter.dpToPx(44)
+ val bgTintColor = matrixItemColorProvider.getColor(userItem)
+ avatarRenderer.render(glideRequests, userItem, object : CustomTarget(size, size) {
+ override fun onResourceReady(resource: Drawable, transition: Transition?) {
+ Timber.d("## Location: onResourceReady")
+ val pinDrawable = createPinDrawable(resource, bgTintColor)
+ cache[userId] = pinDrawable
+ callback(pinDrawable)
+ }
- override fun onLoadCleared(placeholder: Drawable?) {
- // Is it possible? Put placeholder instead?
- // FIXME The doc says it has to be implemented and should free resources
- Timber.d("## Location: onLoadCleared")
- }
+ override fun onLoadCleared(placeholder: Drawable?) {
+ // Is it possible? Put placeholder instead?
+ // FIXME The doc says it has to be implemented and should free resources
+ Timber.d("## Location: onLoadCleared")
+ }
- override fun onLoadFailed(errorDrawable: Drawable?) {
- Timber.w("## Location: onLoadFailed")
- errorDrawable ?: return
- val pinDrawable = createPinDrawable(errorDrawable)
- cache[userId] = pinDrawable
- callback(pinDrawable)
+ override fun onLoadFailed(errorDrawable: Drawable?) {
+ Timber.w("## Location: onLoadFailed")
+ errorDrawable ?: return
+ val pinDrawable = createPinDrawable(errorDrawable, bgTintColor)
+ cache[userId] = pinDrawable
+ callback(pinDrawable)
+ }
+ })
}
- })
- }
}
- private fun createPinDrawable(drawable: Drawable): Drawable {
+ private fun createPinDrawable(drawable: Drawable, @ColorInt bgTintColor: Int): Drawable {
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!!
+ // use mutate on drawable to avoid sharing the color when we have multiple different user pins
+ DrawableCompat.setTint(bgUserPin.mutate(), bgTintColor)
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, drawable))
val horizontalInset = dimensionConverter.dpToPx(4)
val topInset = dimensionConverter.dpToPx(4)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt
index 426561054b..45c711ff93 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt
@@ -22,6 +22,7 @@ import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
+import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents
@@ -33,6 +34,7 @@ class MessageItemAttributesFactory @Inject constructor(
private val messageColorProvider: MessageColorProvider,
private val avatarSizeProvider: AvatarSizeProvider,
private val stringProvider: StringProvider,
+ private val displayableEventFormatter: DisplayableEventFormatter,
private val preferencesProvider: UserPreferencesProvider,
private val emojiCompatFontProvider: EmojiCompatFontProvider) {
@@ -61,6 +63,7 @@ class MessageItemAttributesFactory @Inject constructor(
readReceiptsCallback = callback,
emojiTypeFace = emojiCompatFontProvider.typeface,
decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message),
+ threadSummaryFormatted = displayableEventFormatter.formatThreadSummary(threadDetails?.threadSummaryLatestEvent).toString(),
threadDetails = threadDetails,
reactionsSummaryEvents = reactionsSummaryEvents,
areThreadMessagesEnabled = preferencesProvider.areThreadMessagesEnabled()
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt
index 53a9fbbaea..96a2ca4609 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt
@@ -50,9 +50,8 @@ object TimelineDisplayableEvents {
EventType.STATE_ROOM_TOMBSTONE,
EventType.STATE_ROOM_JOIN_RULES,
EventType.KEY_VERIFICATION_DONE,
- EventType.KEY_VERIFICATION_CANCEL,
- EventType.POLL_START
- )
+ EventType.KEY_VERIFICATION_CANCEL
+ ) + EventType.POLL_START
}
fun TimelineEvent.canBeMerged(): Boolean {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt
index b762bd23d2..cf80548d37 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt
@@ -82,41 +82,47 @@ abstract class AbsMessageItem : AbsBaseMessageItem
override fun bind(holder: H) {
super.bind(holder)
+
if ((holder.view as? ScMessageBubbleWrapView)?.customBind(this, holder, attributes, _avatarClickListener, _memberNameClickListener) != true) {
- if (attributes.informationData.messageLayout.showAvatar) {
- holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply {
- height = attributes.avatarSize
- width = attributes.avatarSize
- }
- attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView)
- holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener)
- holder.avatarImageView.isVisible = true
- holder.avatarImageView.onClick(_avatarClickListener)
- } else {
- holder.avatarImageView.setOnClickListener(null)
- holder.avatarImageView.setOnLongClickListener(null)
- holder.avatarImageView.isVisible = false
+ // Wrong indention for merge-ability
+
+ if (attributes.informationData.messageLayout.showAvatar) {
+ holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply {
+ height = attributes.avatarSize
+ width = attributes.avatarSize
}
- if (attributes.informationData.messageLayout.showDisplayName) {
- holder.memberNameView.isVisible = true
- holder.memberNameView.text = attributes.informationData.memberName
- holder.memberNameView.setTextColor(attributes.getMemberNameColor())
- holder.memberNameView.onClick(_memberNameClickListener)
- holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener)
- } else {
- holder.memberNameView.setOnClickListener(null)
- holder.memberNameView.setOnLongClickListener(null)
- holder.memberNameView.isVisible = false
- }
- if (attributes.informationData.messageLayout.showTimestamp) {
- holder.timeView.isVisible = true
- holder.timeView.text = attributes.informationData.time
- } else {
- holder.timeView.isVisible = false
- }
- // Render send state indicator
- holder.sendStateImageView.render(attributes.informationData.sendStateDecoration)
- holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
+ attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView)
+ holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener)
+ holder.avatarImageView.isVisible = true
+ holder.avatarImageView.onClick(_avatarClickListener)
+ } else {
+ holder.avatarImageView.setOnClickListener(null)
+ holder.avatarImageView.setOnLongClickListener(null)
+ holder.avatarImageView.isVisible = false
+ }
+ if (attributes.informationData.messageLayout.showDisplayName) {
+ holder.memberNameView.isVisible = true
+ holder.memberNameView.text = attributes.informationData.memberName
+ holder.memberNameView.setTextColor(attributes.getMemberNameColor())
+ holder.memberNameView.onClick(_memberNameClickListener)
+ holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener)
+ } else {
+ holder.memberNameView.setOnClickListener(null)
+ holder.memberNameView.setOnLongClickListener(null)
+ holder.memberNameView.isVisible = false
+ }
+ if (attributes.informationData.messageLayout.showTimestamp) {
+ holder.timeView.isVisible = true
+ holder.timeView.text = attributes.informationData.time
+ } else {
+ holder.timeView.isVisible = false
+ }
+
+ // Render send state indicator
+ holder.sendStateImageView.render(attributes.informationData.sendStateDecoration)
+ holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
+
+ // Wrong indention for merge-ability - end
}
// Threads
@@ -125,7 +131,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem
attributes.threadDetails?.let { threadDetails ->
holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread
holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString()
- holder.threadSummaryInfoTextView.text = threadDetails.threadSummaryLatestTextMessage ?: attributes.decryptionErrorMessage
+ holder.threadSummaryInfoTextView.text = attributes.threadSummaryFormatted ?: attributes.decryptionErrorMessage
val userId = threadDetails.threadSummarySenderInfo?.userId ?: return@let
val displayName = threadDetails.threadSummarySenderInfo?.displayName
@@ -193,6 +199,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem
val isNotice: Boolean = false,
val emojiTypeFace: Typeface? = null,
val decryptionErrorMessage: String? = null,
+ val threadSummaryFormatted: String? = null,
val threadDetails: ThreadDetails? = null,
val areThreadMessagesEnabled: Boolean = false,
override val reactionsSummaryEvents: ReactionsSummaryEvents? = null,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt
index c4242bb3b4..475ae2e099 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt
@@ -23,6 +23,7 @@ import im.vector.app.features.themes.ScBubbleAppearance
import kotlinx.parcelize.Parcelize
sealed interface TimelineMessageLayout : Parcelable {
+
val layoutRes: Int
val showAvatar: Boolean
val showDisplayName: Boolean
@@ -30,36 +31,39 @@ sealed interface TimelineMessageLayout : Parcelable {
val showE2eDecoration: Boolean
@Parcelize
- data class Default(override val showAvatar: Boolean,
- override val showDisplayName: Boolean,
- override val showTimestamp: Boolean,
- override val showE2eDecoration: Boolean,
- // Keep defaultLayout generated on epoxy items
- override val layoutRes: Int = 0) : TimelineMessageLayout
+ data class Default(
+ override val showAvatar: Boolean,
+ override val showDisplayName: Boolean,
+ override val showTimestamp: Boolean,
+ override val showE2eDecoration: Boolean,
+ // Keep defaultLayout generated on epoxy items
+ override val layoutRes: Int = 0,
+ ) : TimelineMessageLayout
@Parcelize
data class Bubble(
- override val showAvatar: Boolean,
- override val showDisplayName: Boolean,
- override val showTimestamp: Boolean = true,
- override val showE2eDecoration: Boolean = true,
- val isIncoming: Boolean,
- val isPseudoBubble: Boolean,
- val cornersRadius: CornersRadius,
- val timestampAsOverlay: Boolean,
- override val layoutRes: Int = if (isIncoming) {
- R.layout.item_timeline_event_bubble_incoming_base
- } else {
- R.layout.item_timeline_event_bubble_outgoing_base
- }
+ override val showAvatar: Boolean,
+ override val showDisplayName: Boolean,
+ override val showTimestamp: Boolean = true,
+ override val showE2eDecoration: Boolean = true,
+ val addTopMargin: Boolean = false,
+ val isIncoming: Boolean,
+ val isPseudoBubble: Boolean,
+ val cornersRadius: CornersRadius,
+ val timestampAsOverlay: Boolean,
+ override val layoutRes: Int = if (isIncoming) {
+ R.layout.item_timeline_event_bubble_incoming_base
+ } else {
+ R.layout.item_timeline_event_bubble_outgoing_base
+ },
) : TimelineMessageLayout {
@Parcelize
data class CornersRadius(
- val topStartRadius: Float,
- val topEndRadius: Float,
- val bottomStartRadius: Float,
- val bottomEndRadius: Float
+ val topStartRadius: Float,
+ val topEndRadius: Float,
+ val bottomStartRadius: Float,
+ val bottomEndRadius: Float,
) : Parcelable
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
index 2532abdf21..16ca536315 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
@@ -46,10 +46,9 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
// Can be rendered in bubbles, other types will fallback to default
private val EVENT_TYPES_WITH_BUBBLE_LAYOUT = setOf(
EventType.MESSAGE,
- EventType.POLL_START,
EventType.ENCRYPTED,
EventType.STICKER
- )
+ ) + EventType.POLL_START
// Can't be rendered in bubbles, so get back to default layout
private val MSG_TYPES_WITHOUT_BUBBLE_LAYOUT = setOf(
@@ -149,6 +148,7 @@ class TimelineMessageLayoutFactory @Inject constructor(private val session: Sess
TimelineMessageLayout.Bubble(
showAvatar = showInformation && !isSentByMe,
showDisplayName = showInformation && !isSentByMe,
+ addTopMargin = isFirstFromThisSender && isSentByMe,
isIncoming = !isSentByMe,
cornersRadius = cornersRadius,
isPseudoBubble = messageContent.isPseudoBubble(),
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt
index a64f20e801..023fd03969 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt
@@ -44,9 +44,11 @@ import im.vector.app.features.home.room.detail.timeline.style.shapeAppearanceMod
import im.vector.app.features.themes.ThemeUtils
import timber.log.Timber
-class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
- defStyleAttr: Int = 0) :
- RelativeLayout(context, attrs, defStyleAttr), TimelineMessageLayoutRenderer {
+class MessageBubbleView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : RelativeLayout(context, attrs, defStyleAttr), TimelineMessageLayoutRenderer {
private var isIncoming: Boolean = false
@@ -89,22 +91,43 @@ class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: Attri
outlineProvider = ViewOutlineProvider.BACKGROUND
clipToOutline = true
background = RippleDrawable(
- ContextCompat.getColorStateList(context, R.color.mtrl_btn_ripple_color) ?: ColorStateList.valueOf(Color.TRANSPARENT),
- bubbleDrawable,
- rippleMaskDrawable)
+ ContextCompat.getColorStateList(context, R.color.mtrl_btn_ripple_color) ?: ColorStateList.valueOf(Color.TRANSPARENT),
+ bubbleDrawable,
+ rippleMaskDrawable)
}
}
override fun renderMessageLayout(messageLayout: TimelineMessageLayout, bubbleDependentView: BubbleDependentView, holder: H) {
- if (messageLayout !is TimelineMessageLayout.Bubble) {
- Timber.v("Can't render messageLayout $messageLayout")
- return
+ (messageLayout as? TimelineMessageLayout.Bubble)
+ ?.updateDrawables()
+ ?.setConstraintsAndColor()
+ ?.toggleMessageOverlay()
+ ?.setPadding()
+ ?.setMargins()
+ ?.setAdditionalTopSpace()
+ ?: Timber.v("Can't render messageLayout $messageLayout")
+ }
+
+ private fun TimelineMessageLayout.Bubble.updateDrawables() = apply {
+ val shapeAppearanceModel = cornersRadius.shapeAppearanceModel()
+ bubbleDrawable.apply {
+ this.shapeAppearanceModel = shapeAppearanceModel
+ this.fillColor = if (isPseudoBubble) {
+ ColorStateList.valueOf(Color.TRANSPARENT)
+ } else {
+ val backgroundColorAttr = if (isIncoming) R.attr.vctr_message_bubble_inbound else R.attr.vctr_message_bubble_outbound
+ val backgroundColor = ThemeUtils.getColor(context, backgroundColorAttr)
+ ColorStateList.valueOf(backgroundColor)
+ }
}
- updateDrawables(messageLayout)
+ rippleMaskDrawable.shapeAppearanceModel = shapeAppearanceModel
+ }
+
+ private fun TimelineMessageLayout.Bubble.setConstraintsAndColor() = apply {
ConstraintSet().apply {
clone(views.bubbleView)
clear(R.id.viewStubContainer, ConstraintSet.END)
- if (messageLayout.timestampAsOverlay) {
+ if (timestampAsOverlay) {
val timeColor = ContextCompat.getColor(context, R.color.palette_white)
views.messageTimeView.setTextColor(timeColor)
connect(R.id.viewStubContainer, ConstraintSet.END, R.id.parent, ConstraintSet.END, 0)
@@ -115,17 +138,26 @@ class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: Attri
}
applyTo(views.bubbleView)
}
- if (messageLayout.timestampAsOverlay) {
+ }
+
+ private fun TimelineMessageLayout.Bubble.toggleMessageOverlay() = apply {
+ if (timestampAsOverlay) {
views.messageOverlayView.isVisible = true
- (views.messageOverlayView.background as? GradientDrawable)?.cornerRadii = messageLayout.cornersRadius.toFloatArray()
+ (views.messageOverlayView.background as? GradientDrawable)?.cornerRadii = cornersRadius.toFloatArray()
} else {
views.messageOverlayView.isVisible = false
}
- if (messageLayout.isPseudoBubble && messageLayout.timestampAsOverlay) {
+ }
+
+ private fun TimelineMessageLayout.Bubble.setPadding() = apply {
+ if (isPseudoBubble && timestampAsOverlay) {
views.viewStubContainer.root.setPadding(0, 0, 0, 0)
} else {
views.viewStubContainer.root.setPadding(horizontalStubPadding, verticalStubPadding, horizontalStubPadding, verticalStubPadding)
}
+ }
+
+ private fun TimelineMessageLayout.Bubble.setMargins() = apply {
if (isIncoming) {
views.messageEndGuideline.updateLayoutParams {
marginEnd = resources.getDimensionPixelSize(R.dimen.chat_bubble_margin_end)
@@ -143,22 +175,11 @@ class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: Attri
}
}
+ private fun TimelineMessageLayout.Bubble.setAdditionalTopSpace() = apply {
+ views.additionalTopSpace.isVisible = addTopMargin
+ }
+
private fun TimelineMessageLayout.Bubble.CornersRadius.toFloatArray(): FloatArray {
return floatArrayOf(topStartRadius, topStartRadius, topEndRadius, topEndRadius, bottomEndRadius, bottomEndRadius, bottomStartRadius, bottomStartRadius)
}
-
- private fun updateDrawables(messageLayout: TimelineMessageLayout.Bubble) {
- val shapeAppearanceModel = messageLayout.cornersRadius.shapeAppearanceModel()
- bubbleDrawable.apply {
- this.shapeAppearanceModel = shapeAppearanceModel
- this.fillColor = if (messageLayout.isPseudoBubble) {
- ColorStateList.valueOf(Color.TRANSPARENT)
- } else {
- val backgroundColorAttr = if (isIncoming) R.attr.vctr_message_bubble_inbound else R.attr.vctr_message_bubble_outbound
- val backgroundColor = ThemeUtils.getColor(context, backgroundColorAttr)
- ColorStateList.valueOf(backgroundColor)
- }
- }
- rippleMaskDrawable.shapeAppearanceModel = shapeAppearanceModel
- }
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomCategoryItem.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomCategoryItem.kt
index 696b8b0626..feb4abc6b8 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomCategoryItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomCategoryItem.kt
@@ -33,6 +33,7 @@ import im.vector.app.features.themes.ThemeUtils
abstract class RoomCategoryItem : VectorEpoxyModel() {
@EpoxyAttribute lateinit var title: String
+ @EpoxyAttribute var itemCount: Int = 0
@EpoxyAttribute var expanded: Boolean = false
@EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var unreadMessages: Int = 0
@@ -48,14 +49,16 @@ abstract class RoomCategoryItem : VectorEpoxyModel() {
DrawableCompat.setTint(it, tintColor)
}
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted, unreadMessages, markedUnread))
- holder.titleView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
holder.titleView.text = title
+ holder.counterView.text = itemCount.takeIf { it > 0 }?.toString().orEmpty()
+ holder.counterView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
holder.rootView.onClick(listener)
}
class Holder : VectorEpoxyHolder() {
val unreadCounterBadgeView by bind(R.id.roomCategoryUnreadCounterBadgeView)
val titleView by bind(R.id.roomCategoryTitleView)
+ val counterView by bind(R.id.roomCategoryCounterView)
val rootView by bind(R.id.roomCategoryRootView)
}
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt
index 805325d3e1..52593dc4ed 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt
@@ -24,6 +24,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.ConcatAdapter
@@ -56,8 +58,11 @@ import im.vector.app.features.home.room.list.widget.NotifsFabMenuView
import im.vector.app.features.notifications.NotificationDrawerManager
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.space
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@@ -170,8 +175,10 @@ class RoomListFragment @Inject constructor(
}
private fun refreshCollapseStates() {
+ val sectionsCount = adapterInfosList.count { !it.sectionHeaderAdapter.roomsSectionData.isHidden }
roomListViewModel.sections.forEachIndexed { index, roomsSection ->
val actualBlock = adapterInfosList[index]
+ val isRoomSectionCollapsable = sectionsCount > 1
val isRoomSectionExpanded = roomsSection.isExpanded.value.orTrue()
if (actualBlock.section.isExpanded && !isRoomSectionExpanded) {
// mark controller as collapsed
@@ -180,12 +187,18 @@ class RoomListFragment @Inject constructor(
// we must expand!
actualBlock.contentEpoxyController.setCollapsed(false)
}
- actualBlock.section = actualBlock.section.copy(
- isExpanded = isRoomSectionExpanded
- )
- actualBlock.sectionHeaderAdapter.updateSection(
- actualBlock.sectionHeaderAdapter.roomsSectionData.copy(isExpanded = isRoomSectionExpanded)
- )
+ actualBlock.section = actualBlock.section.copy(isExpanded = isRoomSectionExpanded)
+ actualBlock.sectionHeaderAdapter.updateSection {
+ it.copy(
+ isExpanded = isRoomSectionExpanded,
+ isCollapsable = isRoomSectionCollapsable
+ )
+ }
+
+ if (!isRoomSectionExpanded && !isRoomSectionCollapsable) {
+ // force expand if the section is not collapsable
+ roomListViewModel.handle(RoomListAction.ToggleSection(roomsSection))
+ }
}
}
@@ -305,13 +318,12 @@ class RoomListFragment @Inject constructor(
val concatAdapter = ConcatAdapter()
- roomListViewModel.sections.forEach { section ->
- val sectionAdapter = SectionHeaderAdapter {
- roomListViewModel.handle(RoomListAction.ToggleSection(section))
- }.also {
- it.updateSection(SectionHeaderAdapter.RoomsSectionData(section.sectionName))
+ roomListViewModel.sections.forEachIndexed { index, section ->
+ val sectionAdapter = SectionHeaderAdapter(SectionHeaderAdapter.RoomsSectionData(section.sectionName)) {
+ if (adapterInfosList[index].sectionHeaderAdapter.roomsSectionData.isCollapsable) {
+ roomListViewModel.handle(RoomListAction.ToggleSection(section))
+ }
}
-
val contentAdapter =
when {
section.livePages != null -> {
@@ -319,19 +331,25 @@ class RoomListFragment @Inject constructor(
.also { controller ->
section.livePages.observe(viewLifecycleOwner) { pl ->
controller.submitList(pl)
- sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
- isHidden = pl.isEmpty(),
- isLoading = false
- ))
+ sectionAdapter.updateSection {
+ it.copy(
+ isHidden = pl.isEmpty(),
+ isLoading = false
+ )
+ }
+ refreshCollapseStates()
checkEmptyState()
}
+ observeItemCount(section, sectionAdapter)
section.notificationCount.observe(viewLifecycleOwner) { counts ->
- sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
- notificationCount = counts.totalCount,
- isHighlighted = counts.isHighlight,
- unread = counts.unreadCount,
- markedUnread = false
- ))
+ sectionAdapter.updateSection {
+ it.copy(
+ notificationCount = counts.totalCount,
+ isHighlighted = counts.isHighlight,
+ unread = counts.unreadCount,
+ markedUnread = false
+ )
+ }
}
section.isExpanded.observe(viewLifecycleOwner) { _ ->
refreshCollapseStates()
@@ -344,12 +362,16 @@ class RoomListFragment @Inject constructor(
.also { controller ->
section.liveSuggested.observe(viewLifecycleOwner) { info ->
controller.setData(info)
- sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
- isHidden = info.rooms.isEmpty(),
- isLoading = false
- ))
+ sectionAdapter.updateSection {
+ it.copy(
+ isHidden = info.rooms.isEmpty(),
+ isLoading = false
+ )
+ }
+ refreshCollapseStates()
checkEmptyState()
}
+ observeItemCount(section, sectionAdapter)
section.isExpanded.observe(viewLifecycleOwner) { _ ->
refreshCollapseStates()
}
@@ -361,18 +383,25 @@ class RoomListFragment @Inject constructor(
.also { controller ->
section.liveList?.observe(viewLifecycleOwner) { list ->
controller.setData(list)
- sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
- isHidden = list.isEmpty(),
- isLoading = false))
+ sectionAdapter.updateSection {
+ it.copy(
+ isHidden = list.isEmpty(),
+ isLoading = false,
+ )
+ }
+ refreshCollapseStates()
checkEmptyState()
}
+ observeItemCount(section, sectionAdapter)
section.notificationCount.observe(viewLifecycleOwner) { counts ->
- sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(
- notificationCount = counts.totalCount,
- isHighlighted = counts.isHighlight,
- unread = counts.unreadCount,
- markedUnread = false
- ))
+ sectionAdapter.updateSection {
+ it.copy(
+ notificationCount = counts.totalCount,
+ isHighlighted = counts.isHighlight,
+ unread = counts.unreadCount,
+ markedUnread = false
+ )
+ }
}
section.isExpanded.observe(viewLifecycleOwner) { _ ->
refreshCollapseStates()
@@ -421,6 +450,19 @@ class RoomListFragment @Inject constructor(
}
}
+ private fun observeItemCount(section: RoomsSection, sectionAdapter: SectionHeaderAdapter) {
+ lifecycleScope.launch {
+ section.itemCount
+ .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
+ .filter { it > 0 }
+ .collect { count ->
+ sectionAdapter.updateSection {
+ it.copy(itemCount = count)
+ }
+ }
+ }
+ }
+
private fun handleQuickActions(quickAction: RoomListQuickActionsSharedAction) {
when (quickAction) {
is RoomListQuickActionsSharedAction.NotificationsAllNoisy -> {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderGroup.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderGroup.kt
index 78fb2e8df4..a7019b8aa9 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderGroup.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderGroup.kt
@@ -28,6 +28,7 @@ import im.vector.app.features.invite.showInvites
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -73,11 +74,20 @@ class RoomListSectionBuilderGroup(
},
{ qpm ->
val name = stringProvider.getString(R.string.bottom_action_rooms)
- session.getFilteredPagedRoomSummariesLive(qpm)
- .let { updatableFilterLivePageResult ->
- onUpdatable(updatableFilterLivePageResult)
- sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList))
- }
+ val updatableFilterLivePageResult = session.getFilteredPagedRoomSummariesLive(qpm)
+ onUpdatable(updatableFilterLivePageResult)
+
+ val itemCountFlow = updatableFilterLivePageResult.livePagedList.asFlow()
+ .flatMapLatest { session.getRoomCountLive(updatableFilterLivePageResult.queryParams).asFlow() }
+ .distinctUntilChanged()
+
+ sections.add(
+ RoomsSection(
+ sectionName = name,
+ livePages = updatableFilterLivePageResult.livePagedList,
+ itemCount = itemCountFlow
+ )
+ )
}
)
}
@@ -113,9 +123,7 @@ class RoomListSectionBuilderGroup(
.onEach { groupingMethod ->
val selectedGroupId = (groupingMethod.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId
activeGroupAwareQueries.onEach { updater ->
- updater.updateQuery { query ->
- query.copy(activeGroupId = selectedGroupId)
- }
+ updater.queryParams = updater.queryParams.copy(activeGroupId = selectedGroupId)
}
}.launchIn(coroutineScope)
@@ -304,36 +312,33 @@ class RoomListSectionBuilderGroup(
@StringRes nameRes: Int,
notifyOfLocalEcho: Boolean = false,
query: (RoomSummaryQueryParams.Builder) -> Unit) {
- withQueryParams(
- { query.invoke(it) },
- { roomQueryParams ->
- val name = stringProvider.getString(nameRes)
- session.getFilteredPagedRoomSummariesLive(roomQueryParams)
- .also {
- activeSpaceUpdaters.add(it)
- }.livePagedList
- .let { livePagedList ->
- // use it also as a source to update count
- livePagedList.asFlow()
- .onEach {
- sections.find { it.sectionName == name }
- ?.notificationCount
- ?.postValue(session.getNotificationCountForRooms(roomQueryParams))
- }
- .flowOn(Dispatchers.Default)
- .launchIn(coroutineScope)
+ withQueryParams(query) { roomQueryParams ->
+ val name = stringProvider.getString(nameRes)
+ session.getFilteredPagedRoomSummariesLive(roomQueryParams)
+ .also {
+ activeSpaceUpdaters.add(it)
+ }.livePagedList
+ .let { livePagedList ->
+ // use it also as a source to update count
+ livePagedList.asFlow()
+ .onEach {
+ sections.find { it.sectionName == name }
+ ?.notificationCount
+ ?.postValue(session.getNotificationCountForRooms(roomQueryParams))
+ }
+ .flowOn(Dispatchers.Default)
+ .launchIn(coroutineScope)
- sections.add(
- RoomsSection(
- sectionName = name,
- livePages = livePagedList,
- notifyOfLocalEcho = notifyOfLocalEcho
- )
+ sections.add(
+ RoomsSection(
+ sectionName = name,
+ livePages = livePagedList,
+ notifyOfLocalEcho = notifyOfLocalEcho,
+ itemCount = session.getRoomCountLive(roomQueryParams).asFlow()
)
- }
- }
-
- )
+ )
+ }
+ }
}
private fun withQueryParams(builder: (RoomSummaryQueryParams.Builder) -> Unit, block: (RoomSummaryQueryParams) -> Unit) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderSpace.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderSpace.kt
index d99cac5d17..b5f0ad3915 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderSpace.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilderSpace.kt
@@ -32,13 +32,16 @@ import im.vector.app.features.invite.showInvites
import im.vector.app.space
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.query.ActiveSpaceFilter
import org.matrix.android.sdk.api.query.RoomCategoryFilter
@@ -90,55 +93,10 @@ class RoomListSectionBuilderSpace(
}
RoomListDisplayMode.FILTERED -> {
// Used when searching for rooms
- withQueryParams(
- {
- it.memberships = Membership.activeMemberships()
- },
- { qpm ->
- val name = stringProvider.getString(R.string.bottom_action_rooms)
- session.getFilteredPagedRoomSummariesLive(qpm)
- .let { updatableFilterLivePageResult ->
- onUpdatable(updatableFilterLivePageResult)
- sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList))
- }
- }
- )
+ buildFilteredSection(sections)
}
RoomListDisplayMode.NOTIFICATIONS -> {
- if (autoAcceptInvites.showInvites()) {
- addSection(
- sections = sections,
- activeSpaceUpdaters = activeSpaceAwareQueries,
- nameRes = R.string.invitations_header,
- notifyOfLocalEcho = true,
- explicitSpaceId = explicitSpaceId,
- spaceFilterStrategy = if (onlyOrphansInHome) {
- RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL
- } else {
- RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL
- },
- countRoomAsNotif = true
- ) {
- it.memberships = listOf(Membership.INVITE)
- it.roomCategoryFilter = RoomCategoryFilter.ALL
- }
- }
-
- addSection(
- sections = sections,
- activeSpaceUpdaters = activeSpaceAwareQueries,
- nameRes = R.string.bottom_action_rooms,
- notifyOfLocalEcho = false,
- explicitSpaceId = explicitSpaceId,
- spaceFilterStrategy = if (onlyOrphansInHome) {
- RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL
- } else {
- RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL
- }
- ) {
- it.memberships = listOf(Membership.JOIN)
- it.roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS
- }
+ buildNotificationsSection(sections, activeSpaceAwareQueries, explicitSpaceId)
}
}
@@ -390,7 +348,8 @@ class RoomListSectionBuilderSpace(
RoomsSection(
sectionName = stringProvider.getString(R.string.suggested_header),
liveSuggested = liveSuggestedRooms,
- notifyOfLocalEcho = false
+ notifyOfLocalEcho = false,
+ itemCount = suggestedRoomsFlow.map { suggestions -> suggestions.size }
)
)
}
@@ -466,6 +425,71 @@ class RoomListSectionBuilderSpace(
}
}
+ private fun buildNotificationsSection(sections: MutableList,
+ activeSpaceAwareQueries: MutableList,
+ explicitSpaceId: String?) {
+ if (autoAcceptInvites.showInvites()) {
+ addSection(
+ sections = sections,
+ activeSpaceUpdaters = activeSpaceAwareQueries,
+ nameRes = R.string.invitations_header,
+ notifyOfLocalEcho = true,
+ explicitSpaceId = explicitSpaceId,
+ spaceFilterStrategy = if (onlyOrphansInHome) {
+ RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL
+ } else {
+ RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL
+ },
+ countRoomAsNotif = true
+ ) {
+ it.memberships = listOf(Membership.INVITE)
+ it.roomCategoryFilter = RoomCategoryFilter.ALL
+ }
+ }
+
+ addSection(
+ sections = sections,
+ activeSpaceUpdaters = activeSpaceAwareQueries,
+ nameRes = R.string.bottom_action_rooms,
+ notifyOfLocalEcho = false,
+ explicitSpaceId = explicitSpaceId,
+ spaceFilterStrategy = if (onlyOrphansInHome) {
+ RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL
+ } else {
+ RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL
+ }
+ ) {
+ it.memberships = listOf(Membership.JOIN)
+ it.roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS
+ }
+ }
+
+ private fun buildFilteredSection(sections: MutableList) {
+ // Used when searching for rooms
+ withQueryParams(
+ {
+ it.memberships = Membership.activeMemberships()
+ },
+ { qpm ->
+ val name = stringProvider.getString(R.string.bottom_action_rooms)
+ val updatableFilterLivePageResult = session.getFilteredPagedRoomSummariesLive(qpm)
+ onUpdatable(updatableFilterLivePageResult)
+
+ val itemCountFlow = updatableFilterLivePageResult.livePagedList.asFlow()
+ .flatMapLatest { session.getRoomCountLive(updatableFilterLivePageResult.queryParams).asFlow() }
+ .distinctUntilChanged()
+
+ sections.add(
+ RoomsSection(
+ sectionName = name,
+ livePages = updatableFilterLivePageResult.livePagedList,
+ itemCount = itemCountFlow
+ )
+ )
+ }
+ )
+ }
+
private fun addSection(sections: MutableList,
activeSpaceUpdaters: MutableList,
@StringRes nameRes: Int,
@@ -474,81 +498,82 @@ class RoomListSectionBuilderSpace(
countRoomAsNotif: Boolean = false,
explicitSpaceId: String?,
query: (RoomSummaryQueryParams.Builder) -> Unit) {
- withQueryParams(
- { query.invoke(it) },
- { roomQueryParams ->
- val name = stringProvider.getString(nameRes)
- session.getFilteredPagedRoomSummariesLive(
- roomQueryParams.process(spaceFilterStrategy, appStateHandler.explicitOrSafeActiveSpaceId(explicitSpaceId)),
- pagedListConfig
- ).also {
- when (spaceFilterStrategy) {
- RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL -> {
- activeSpaceUpdaters.add(object : RoomListViewModel.ActiveSpaceQueryUpdater {
- override fun updateForSpaceId(roomId: String?) {
- it.updateQuery {
- it.copy(
- activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
- )
- }
- }
- })
- }
- RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL -> {
- activeSpaceUpdaters.add(object : RoomListViewModel.ActiveSpaceQueryUpdater {
- override fun updateForSpaceId(roomId: String?) {
- if (roomId != null) {
- it.updateQuery {
- it.copy(
- activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
- )
- }
- } else {
- it.updateQuery {
- it.copy(
- activeSpaceFilter = ActiveSpaceFilter.None
- )
- }
- }
- }
- })
- }
- RoomListViewModel.SpaceFilterStrategy.NONE -> {
- // we ignore current space for this one
- }
- }
- }.livePagedList
- .let { livePagedList ->
- // use it also as a source to update count
- livePagedList.asFlow()
- .onEach {
- Timber.v("Thread space list: ${Thread.currentThread()}")
- sections.find { it.sectionName == name }
- ?.notificationCount
- ?.postValue(
- if (countRoomAsNotif) {
- RoomAggregateNotificationCount(it.size, it.size, 0)
- } else {
- session.getNotificationCountForRooms(
- roomQueryParams.process(spaceFilterStrategy, appStateHandler.explicitOrSafeActiveSpaceId(explicitSpaceId))
- )
- }
- )
- }
- .flowOn(Dispatchers.Default)
- .launchIn(viewModelScope)
+ withQueryParams(query) { roomQueryParams ->
+ val updatedQueryParams = roomQueryParams.process(spaceFilterStrategy, appStateHandler.explicitOrSafeActiveSpaceId(explicitSpaceId))
+ val liveQueryParams = MutableStateFlow(updatedQueryParams)
+ val itemCountFlow = liveQueryParams
+ .flatMapLatest {
+ session.getRoomCountLive(it).asFlow()
+ }
+ .flowOn(Dispatchers.Main)
+ .distinctUntilChanged()
- sections.add(
- RoomsSection(
- sectionName = name,
- livePages = livePagedList,
- notifyOfLocalEcho = notifyOfLocalEcho
- )
+ val name = stringProvider.getString(nameRes)
+ val filteredPagedRoomSummariesLive = session.getFilteredPagedRoomSummariesLive(
+ roomQueryParams.process(spaceFilterStrategy, appStateHandler.explicitOrSafeActiveSpaceId(explicitSpaceId)),
+ pagedListConfig
+ )
+ when (spaceFilterStrategy) {
+ RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL -> {
+ activeSpaceUpdaters.add(object : RoomListViewModel.ActiveSpaceQueryUpdater {
+ override fun updateForSpaceId(roomId: String?) {
+ filteredPagedRoomSummariesLive.queryParams = roomQueryParams.copy(
+ activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
+ )
+ liveQueryParams.update { filteredPagedRoomSummariesLive.queryParams }
+ }
+ })
+ }
+ RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL -> {
+ activeSpaceUpdaters.add(object : RoomListViewModel.ActiveSpaceQueryUpdater {
+ override fun updateForSpaceId(roomId: String?) {
+ if (roomId != null) {
+ filteredPagedRoomSummariesLive.queryParams = roomQueryParams.copy(
+ activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(roomId)
+ )
+ } else {
+ filteredPagedRoomSummariesLive.queryParams = roomQueryParams.copy(
+ activeSpaceFilter = ActiveSpaceFilter.None
)
}
+ liveQueryParams.update { filteredPagedRoomSummariesLive.queryParams }
+ }
+ })
}
+ RoomListViewModel.SpaceFilterStrategy.NONE -> {
+ // we ignore current space for this one
+ }
+ }
- )
+ val livePagedList = filteredPagedRoomSummariesLive.livePagedList
+ // use it also as a source to update count
+ livePagedList.asFlow()
+ .onEach {
+ Timber.v("Thread space list: ${Thread.currentThread()}")
+ sections.find { it.sectionName == name }
+ ?.notificationCount
+ ?.postValue(
+ if (countRoomAsNotif) {
+ RoomAggregateNotificationCount(it.size, it.size, 0)
+ } else {
+ session.getNotificationCountForRooms(
+ roomQueryParams.process(spaceFilterStrategy, appStateHandler.explicitOrSafeActiveSpaceId(explicitSpaceId))
+ )
+ }
+ )
+ }
+ .flowOn(Dispatchers.Default)
+ .launchIn(viewModelScope)
+
+ sections.add(
+ RoomsSection(
+ sectionName = name,
+ livePages = livePagedList,
+ notifyOfLocalEcho = notifyOfLocalEcho,
+ itemCount = itemCountFlow
+ )
+ )
+ }
}
private fun withQueryParams(builder: (RoomSummaryQueryParams.Builder) -> Unit, block: (RoomSummaryQueryParams) -> Unit) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt
index 64e13bd482..b7e3168ff3 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt
@@ -198,8 +198,8 @@ class RoomListViewModel @AssistedInject constructor(
roomFilter = action.filter
)
}
- updatableQuery?.updateQuery {
- it.copy(
+ updatableQuery?.apply {
+ queryParams = queryParams.copy(
displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.NORMALIZED)
)
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt
index 5cd3cf156c..ae8a71ed24 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt
@@ -29,6 +29,7 @@ import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
+import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@@ -41,7 +42,8 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
private val stringProvider: StringProvider,
private val typingHelper: TypingHelper,
private val avatarRenderer: AvatarRenderer,
- private val errorFormatter: ErrorFormatter) {
+ private val errorFormatter: ErrorFormatter,
+ private val matrixConfiguration: MatrixConfiguration) {
fun create(roomSummary: RoomSummary,
roomChangeMembershipStates: Map,
@@ -125,7 +127,7 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor
// We do not display shield in the room list anymore
// .encryptionTrustLevel(roomSummary.roomEncryptionTrustLevel)
.izPublic(roomSummary.isPublic)
- .showPresence(roomSummary.isDirect)
+ .showPresence(roomSummary.isDirect && matrixConfiguration.presenceSyncEnabled)
.userPresence(roomSummary.directUserPresence)
.matrixItem(roomSummary.toMatrixItem())
.lastEventTime(latestEventTime)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt
index 1620e58ed7..dbc7c324ff 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt
@@ -19,6 +19,7 @@ package im.vector.app.features.home.room.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PagedList
+import kotlinx.coroutines.flow.Flow
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
@@ -29,6 +30,7 @@ data class RoomsSection(
val liveList: LiveData>? = null,
val liveSuggested: LiveData? = null,
val isExpanded: MutableLiveData = MutableLiveData(true),
+ val itemCount: Flow,
val notificationCount: MutableLiveData = MutableLiveData(RoomAggregateNotificationCount(0, 0, 0)),
val notifyOfLocalEcho: Boolean = false
)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt
index c7faa5fc9f..1b23d3db7e 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt
@@ -16,6 +16,7 @@
package im.vector.app.features.home.room.list
+import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
@@ -28,11 +29,13 @@ import im.vector.app.databinding.ItemRoomCategoryBinding
import im.vector.app.features.themes.ThemeUtils
class SectionHeaderAdapter constructor(
+ roomsSectionData: RoomsSectionData,
private val onClickAction: ClickListener
) : RecyclerView.Adapter() {
data class RoomsSectionData(
val name: String,
+ val itemCount: Int = 0,
val isExpanded: Boolean = true,
val notificationCount: Int = 0,
val isHighlighted: Boolean = false,
@@ -42,14 +45,16 @@ class SectionHeaderAdapter constructor(
val markedUnread: Boolean = false,
// This will be false until real data has been submitted once
- val isLoading: Boolean = true
+ val isLoading: Boolean = true,
+ val isCollapsable: Boolean = false
)
- lateinit var roomsSectionData: RoomsSectionData
+ var roomsSectionData: RoomsSectionData = roomsSectionData
private set
- fun updateSection(newRoomsSectionData: RoomsSectionData) {
- if (!::roomsSectionData.isInitialized || newRoomsSectionData != roomsSectionData) {
+ fun updateSection(block: (RoomsSectionData) -> RoomsSectionData) {
+ val newRoomsSectionData = block(roomsSectionData)
+ if (roomsSectionData != newRoomsSectionData) {
roomsSectionData = newRoomsSectionData
notifyDataSetChanged()
}
@@ -85,12 +90,18 @@ class SectionHeaderAdapter constructor(
fun bind(roomsSectionData: RoomsSectionData) {
binding.roomCategoryTitleView.text = roomsSectionData.name
val tintColor = ThemeUtils.getColor(binding.root.context, R.attr.vctr_content_secondary)
- val expandedArrowDrawableRes = if (roomsSectionData.isExpanded) R.drawable.ic_expand_more else R.drawable.ic_expand_less
- val expandedArrowDrawable = ContextCompat.getDrawable(binding.root.context, expandedArrowDrawableRes)?.also {
- DrawableCompat.setTint(it, tintColor)
+ val collapsableArrowDrawable: Drawable? = if (roomsSectionData.isCollapsable) {
+ val expandedArrowDrawableRes = if (roomsSectionData.isExpanded) R.drawable.ic_expand_more else R.drawable.ic_expand_less
+ ContextCompat.getDrawable(binding.root.context, expandedArrowDrawableRes)?.also {
+ DrawableCompat.setTint(it, tintColor)
+ }
+ } else {
+ null
}
+ binding.root.isClickable = roomsSectionData.isCollapsable
+ binding.roomCategoryCounterView.setCompoundDrawablesWithIntrinsicBounds(null, null, collapsableArrowDrawable, null)
+ binding.roomCategoryCounterView.text = roomsSectionData.itemCount.takeIf { it > 0 }?.toString().orEmpty()
binding.roomCategoryUnreadCounterBadgeView.render(UnreadCounterBadgeView.State(roomsSectionData.notificationCount, roomsSectionData.isHighlighted, roomsSectionData.unread, roomsSectionData.markedUnread))
- binding.roomCategoryTitleView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null)
}
companion object {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt
index ca18060c51..726138ed93 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt
@@ -26,13 +26,14 @@ import im.vector.app.core.extensions.addFragmentToBackstack
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityThreadsBinding
+import im.vector.app.features.analytics.extensions.toAnalyticsInteraction
+import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.TimelineFragment
import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
-import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
@AndroidEntryPoint
@@ -92,14 +93,8 @@ class ThreadsActivity : VectorBaseActivity() {
* This function is used to navigate to the selected thread timeline.
* One usage of that is from the Threads Activity
*/
- fun navigateToThreadTimeline(
- timelineEvent: TimelineEvent) {
- val roomThreadDetailArgs = ThreadTimelineArgs(
- roomId = timelineEvent.roomId,
- displayName = timelineEvent.senderInfo.displayName,
- avatarUrl = timelineEvent.senderInfo.avatarUrl,
- roomEncryptionTrustLevel = null,
- rootThreadEventId = timelineEvent.eventId)
+ fun navigateToThreadTimeline(threadTimelineArgs: ThreadTimelineArgs) {
+ analyticsTracker.capture(Interaction.Name.MobileThreadListThreadItem.toAnalyticsInteraction())
val commonOption: (FragmentTransaction) -> Unit = {
it.setCustomAnimations(
R.anim.animation_slide_in_right,
@@ -111,8 +106,8 @@ class ThreadsActivity : VectorBaseActivity() {
container = views.threadsActivityFragmentContainer,
fragmentClass = TimelineFragment::class.java,
params = TimelineArgs(
- roomId = timelineEvent.roomId,
- threadTimelineArgs = roomThreadDetailArgs
+ roomId = threadTimelineArgs.roomId,
+ threadTimelineArgs = threadTimelineArgs
),
option = commonOption
)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt
index aadad3d97c..d3a80811ea 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt
@@ -26,5 +26,6 @@ data class ThreadTimelineArgs(
val displayName: String?,
val avatarUrl: String?,
val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?,
- val rootThreadEventId: String? = null
+ val rootThreadEventId: String? = null,
+ val startsThread: Boolean = false
) : Parcelable
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt
index 8bc6bd73e9..aeef69c6dc 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt
@@ -17,21 +17,26 @@
package im.vector.app.features.home.room.threads.list.viewmodel
import com.airbnb.epoxy.EpoxyController
-import im.vector.app.R
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.AvatarRenderer
+import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.threads.list.model.threadListItem
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.api.util.toMatrixItem
+import org.matrix.android.sdk.api.util.toMatrixItemOrNull
import javax.inject.Inject
class ThreadListController @Inject constructor(
private val avatarRenderer: AvatarRenderer,
private val stringProvider: StringProvider,
- private val dateFormatter: VectorDateFormatter
+ private val dateFormatter: VectorDateFormatter,
+ private val displayableEventFormatter: DisplayableEventFormatter,
+ private val session: Session
) : EpoxyController() {
var listener: Listener? = null
@@ -43,10 +48,68 @@ class ThreadListController @Inject constructor(
requestModelBuild()
}
- override fun buildModels() {
+ override fun buildModels() =
+ when (session.getHomeServerCapabilities().canUseThreading) {
+ true -> buildThreadSummaries()
+ false -> buildThreadList()
+ }
+
+ /**
+ * Building thread summaries when homeserver
+ * supports threading
+ */
+ private fun buildThreadSummaries() {
val safeViewState = viewState ?: return
val host = this
+ safeViewState.threadSummaryList.invoke()
+ ?.filter {
+ if (safeViewState.shouldFilterThreads) {
+ it.isUserParticipating
+ } else {
+ true
+ }
+ }
+ ?.forEach { threadSummary ->
+ val date = dateFormatter.format(threadSummary.latestEvent?.originServerTs, DateFormatKind.ROOM_LIST)
+ val lastMessageFormatted = threadSummary.let {
+ displayableEventFormatter.formatThreadSummary(
+ event = it.latestEvent,
+ latestEdition = it.threadEditions.latestThreadEdition
+ ).toString()
+ }
+ val rootMessageFormatted = threadSummary.let {
+ displayableEventFormatter.formatThreadSummary(
+ event = it.rootEvent,
+ latestEdition = it.threadEditions.rootThreadEdition
+ ).toString()
+ }
+ threadListItem {
+ id(threadSummary.rootEvent?.eventId)
+ avatarRenderer(host.avatarRenderer)
+ matrixItem(threadSummary.rootThreadSenderInfo.toMatrixItem())
+ title(threadSummary.rootThreadSenderInfo.displayName.orEmpty())
+ date(date)
+ rootMessageDeleted(threadSummary.rootEvent?.isRedacted() ?: false)
+ // TODO refactor notifications that with the new thread summary
+ threadNotificationState(threadSummary.rootEvent?.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE)
+ rootMessage(rootMessageFormatted)
+ lastMessage(lastMessageFormatted)
+ lastMessageCounter(threadSummary.numberOfThreads.toString())
+ lastMessageMatrixItem(threadSummary.latestThreadSenderInfo.toMatrixItemOrNull())
+ itemClickListener {
+ host.listener?.onThreadSummaryClicked(threadSummary)
+ }
+ }
+ }
+ }
+ /**
+ * Building local thread list when homeserver do not
+ * support threading
+ */
+ private fun buildThreadList() {
+ val safeViewState = viewState ?: return
+ val host = this
safeViewState.rootThreadEventList.invoke()
?.filter {
if (safeViewState.shouldFilterThreads) {
@@ -59,28 +122,39 @@ class ThreadListController @Inject constructor(
}
?.forEach { timelineEvent ->
val date = dateFormatter.format(timelineEvent.root.threadDetails?.lastMessageTimestamp, DateFormatKind.ROOM_LIST)
- val decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message)
val lastRootThreadEdition = timelineEvent.root.threadDetails?.lastRootThreadEdition
+ val lastMessageFormatted = timelineEvent.root.threadDetails?.threadSummaryLatestEvent.let {
+ displayableEventFormatter.formatThreadSummary(
+ event = it,
+ ).toString()
+ }
+ val rootMessageFormatted = timelineEvent.root.let {
+ displayableEventFormatter.formatThreadSummary(
+ event = it,
+ latestEdition = lastRootThreadEdition
+ ).toString()
+ }
threadListItem {
id(timelineEvent.eventId)
avatarRenderer(host.avatarRenderer)
matrixItem(timelineEvent.senderInfo.toMatrixItem())
- title(timelineEvent.senderInfo.displayName)
+ title(timelineEvent.senderInfo.displayName.orEmpty())
date(date)
rootMessageDeleted(timelineEvent.root.isRedacted())
threadNotificationState(timelineEvent.root.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE)
- rootMessage(lastRootThreadEdition ?: timelineEvent.root.getDecryptedTextSummary() ?: decryptionErrorMessage)
- lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage ?: decryptionErrorMessage)
+ rootMessage(rootMessageFormatted)
+ lastMessage(lastMessageFormatted)
lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString())
lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem())
itemClickListener {
- host.listener?.onThreadClicked(timelineEvent)
+ host.listener?.onThreadListClicked(timelineEvent)
}
}
}
}
interface Listener {
- fun onThreadClicked(timelineEvent: TimelineEvent)
+ fun onThreadSummaryClicked(threadSummary: ThreadSummary)
+ fun onThreadListClicked(timelineEvent: TimelineEvent)
}
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt
index d82b5d6ccf..7f18d172e4 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt
@@ -25,14 +25,19 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
+import im.vector.app.features.analytics.AnalyticsTracker
+import im.vector.app.features.analytics.extensions.toAnalyticsInteraction
+import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent
import org.matrix.android.sdk.flow.flow
class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState: ThreadListViewState,
+ private val analyticsTracker: AnalyticsTracker,
private val session: Session) :
VectorViewModel(initialState) {
@@ -53,11 +58,43 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState
}
init {
- observeThreadsList()
+ fetchAndObserveThreads()
}
override fun handle(action: EmptyAction) {}
+ /**
+ * Observing thread list with respect to homeserver
+ * capabilities
+ */
+ private fun fetchAndObserveThreads() {
+ when (session.getHomeServerCapabilities().canUseThreading) {
+ true -> {
+ fetchThreadList()
+ observeThreadSummaries()
+ }
+ false -> observeThreadsList()
+ }
+ }
+
+ /**
+ * Observing thread summaries when homeserver support
+ * threading
+ */
+ private fun observeThreadSummaries() {
+ room?.flow()
+ ?.liveThreadSummaries()
+ ?.map { room.enhanceThreadWithEditions(it) }
+ ?.flowOn(room.coroutineDispatchers.io)
+ ?.execute { asyncThreads ->
+ copy(threadSummaryList = asyncThreads)
+ }
+ }
+
+ /**
+ * Observing thread list when homeserver do not support
+ * threading
+ */
private fun observeThreadsList() {
room?.flow()
?.liveThreadList()
@@ -74,7 +111,16 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState
}
}
+ private fun fetchThreadList() {
+ viewModelScope.launch {
+ room?.fetchThreadSummaries()
+ }
+ }
+
+ fun canHomeserverUseThreading() = session.getHomeServerCapabilities().canUseThreading
+
fun applyFiltering(shouldFilterThreads: Boolean) {
+ analyticsTracker.capture(Interaction.Name.MobileThreadListFilterItem.toAnalyticsInteraction())
setState {
copy(shouldFilterThreads = shouldFilterThreads)
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt
index 2a70a5be1e..e08f70030b 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt
@@ -20,13 +20,14 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent
data class ThreadListViewState(
+ val threadSummaryList: Async> = Uninitialized,
val rootThreadEventList: Async> = Uninitialized,
val shouldFilterThreads: Boolean = false,
val roomId: String
) : MavericksState {
-
constructor(args: ThreadListArgs) : this(roomId = args.roomId)
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt
index 180e6226d0..d5659efa49 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt
@@ -30,13 +30,16 @@ import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentThreadListBinding
+import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.animation.TimelineItemAnimator
import im.vector.app.features.home.room.threads.ThreadsActivity
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
+import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListController
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject
@@ -60,6 +63,7 @@ class ThreadListFragment @Inject constructor(
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ analyticsScreenName = MobileScreen.ScreenName.ThreadList
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@@ -111,12 +115,30 @@ class ThreadListFragment @Inject constructor(
views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName
}
- override fun onThreadClicked(timelineEvent: TimelineEvent) {
- (activity as? ThreadsActivity)?.navigateToThreadTimeline(timelineEvent)
+ override fun onThreadSummaryClicked(threadSummary: ThreadSummary) {
+ val roomThreadDetailArgs = ThreadTimelineArgs(
+ roomId = threadSummary.roomId,
+ displayName = threadSummary.rootThreadSenderInfo.displayName,
+ avatarUrl = threadSummary.rootThreadSenderInfo.avatarUrl,
+ roomEncryptionTrustLevel = null,
+ rootThreadEventId = threadSummary.rootEventId)
+ (activity as? ThreadsActivity)?.navigateToThreadTimeline(roomThreadDetailArgs)
+ }
+
+ override fun onThreadListClicked(timelineEvent: TimelineEvent) {
+ val threadTimelineArgs = ThreadTimelineArgs(
+ roomId = timelineEvent.roomId,
+ displayName = timelineEvent.senderInfo.displayName,
+ avatarUrl = timelineEvent.senderInfo.avatarUrl,
+ roomEncryptionTrustLevel = null,
+ rootThreadEventId = timelineEvent.eventId)
+ (activity as? ThreadsActivity)?.navigateToThreadTimeline(threadTimelineArgs)
}
private fun renderEmptyStateIfNeeded(state: ThreadListViewState) {
- val show = state.rootThreadEventList.invoke().isNullOrEmpty()
- views.threadListEmptyConstraintLayout.isVisible = show
+ when (threadListViewModel.canHomeserverUseThreading()) {
+ true -> views.threadListEmptyConstraintLayout.isVisible = state.threadSummaryList.invoke().isNullOrEmpty()
+ false -> views.threadListEmptyConstraintLayout.isVisible = state.rootThreadEventList.invoke().isNullOrEmpty()
+ }
}
}
diff --git a/vector/src/main/java/im/vector/app/features/location/DefaultLocationSharingNavigator.kt b/vector/src/main/java/im/vector/app/features/location/DefaultLocationSharingNavigator.kt
new file mode 100644
index 0000000000..8f424af9ec
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/DefaultLocationSharingNavigator.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location
+
+import android.app.Activity
+import im.vector.app.core.utils.openAppSettingsPage
+
+class DefaultLocationSharingNavigator constructor(val activity: Activity?) : LocationSharingNavigator {
+
+ override var goingToAppSettings: Boolean = false
+
+ override fun quit() {
+ activity?.finish()
+ }
+
+ override fun goToAppSettings() {
+ activity?.let {
+ goingToAppSettings = true
+ openAppSettingsPage(it)
+ }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt
index db837f4823..5d823e53a6 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt
@@ -123,7 +123,7 @@ class LocationPreviewFragment @Inject constructor(
views.mapView.render(
MapState(
zoomOnlyOnce = true,
- pinLocationData = location,
+ userLocationData = location,
pinId = args.locationOwnerId ?: DEFAULT_PIN_ID,
pinDrawable = pinDrawable
)
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt
index 01319ef6c7..d7d686ee60 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt
@@ -19,5 +19,9 @@ package im.vector.app.features.location
import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationSharingAction : VectorViewModelAction {
- object OnShareLocation : LocationSharingAction()
+ object CurrentUserLocationSharing : LocationSharingAction()
+ data class PinnedLocationSharing(val locationData: LocationData?) : LocationSharingAction()
+ data class LocationTargetChange(val locationData: LocationData) : LocationSharingAction()
+ object ZoomToUserLocation : LocationSharingAction()
+ object StartLiveLocationSharing : LocationSharingAction()
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
index a7e93a3f6c..c4dccc1b73 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
@@ -16,6 +16,7 @@
package im.vector.app.features.location
+import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -26,10 +27,19 @@ import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.mapbox.mapboxsdk.maps.MapView
+import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorBaseFragment
+import im.vector.app.core.utils.PERMISSIONS_FOR_BACKGROUND_LOCATION_SHARING
+import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
+import im.vector.app.core.utils.checkPermissions
+import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentLocationSharingBinding
+import im.vector.app.features.home.AvatarRenderer
+import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
+import im.vector.app.features.location.option.LocationSharingOption
+import org.matrix.android.sdk.api.util.MatrixItem
import java.lang.ref.WeakReference
import javax.inject.Inject
@@ -37,14 +47,20 @@ import javax.inject.Inject
* We should consider using SupportMapFragment for a out of the box lifecycle handling
*/
class LocationSharingFragment @Inject constructor(
- private val urlMapProvider: UrlMapProvider
-) : VectorBaseFragment() {
+ private val urlMapProvider: UrlMapProvider,
+ private val avatarRenderer: AvatarRenderer,
+ private val matrixItemColorProvider: MatrixItemColorProvider
+) : VectorBaseFragment(), LocationTargetChangeListener {
private val viewModel: LocationSharingViewModel by fragmentViewModel()
+ private val locationSharingNavigator: LocationSharingNavigator by lazy { DefaultLocationSharingNavigator(activity) }
+
// Keep a ref to handle properly the onDestroy callback
private var mapView: WeakReference? = null
+ private var hasRenderedUserAvatar = false
+
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationSharingBinding {
return FragmentLocationSharingBinding.inflate(inflater, container, false)
}
@@ -56,17 +72,20 @@ class LocationSharingFragment @Inject constructor(
views.mapView.onCreate(savedInstanceState)
lifecycleScope.launchWhenCreated {
- views.mapView.initialize(urlMapProvider.getMapUrl())
+ views.mapView.initialize(
+ url = urlMapProvider.getMapUrl(),
+ locationTargetChangeListener = this@LocationSharingFragment
+ )
}
- views.shareLocationContainer.debouncedClicks {
- viewModel.handle(LocationSharingAction.OnShareLocation)
- }
+ initLocateButton()
+ initOptionsPicker()
viewModel.observeViewEvents {
when (it) {
+ LocationSharingViewEvents.Close -> locationSharingNavigator.quit()
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
- LocationSharingViewEvents.Close -> activity?.finish()
+ is LocationSharingViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(it)
}.exhaustive
}
}
@@ -74,6 +93,11 @@ class LocationSharingFragment @Inject constructor(
override fun onResume() {
super.onResume()
views.mapView.onResume()
+ if (locationSharingNavigator.goingToAppSettings) {
+ locationSharingNavigator.goingToAppSettings = false
+ // retry to start live location
+ tryStartLiveLocationSharing()
+ }
}
override fun onPause() {
@@ -107,19 +131,135 @@ class LocationSharingFragment @Inject constructor(
super.onDestroy()
}
+ override fun onLocationTargetChange(target: LocationData) {
+ viewModel.handle(LocationSharingAction.LocationTargetChange(target))
+ }
+
+ override fun invalidate() = withState(viewModel) { state ->
+ updateMap(state)
+ updateUserAvatar(state.userItem)
+ if (state.locationTargetDrawable != null) {
+ updateLocationTargetPin(state.locationTargetDrawable)
+ }
+ views.shareLocationGpsLoading.isGone = state.lastKnownUserLocation != null
+ }
+
private fun handleLocationNotAvailableError() {
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.location_not_available_dialog_title)
.setMessage(R.string.location_not_available_dialog_content)
.setPositiveButton(R.string.ok) { _, _ ->
- activity?.finish()
+ locationSharingNavigator.quit()
}
.setCancelable(false)
.show()
}
- override fun invalidate() = withState(viewModel) { state ->
- views.mapView.render(state.toMapState())
- views.shareLocationGpsLoading.isGone = state.lastKnownLocation != null
+ private fun handleMissingBackgroundLocationPermission() {
+ MaterialAlertDialogBuilder(requireActivity())
+ .setTitle(R.string.location_in_background_missing_permission_dialog_title)
+ .setMessage(R.string.location_in_background_missing_permission_dialog_content)
+ .setPositiveButton(R.string.settings) { _, _ ->
+ locationSharingNavigator.goToAppSettings()
+ }
+ .setNegativeButton(R.string.action_not_now, null)
+ .setCancelable(false)
+ .show()
+ }
+
+ private fun initLocateButton() {
+ views.mapView.locateButton.setOnClickListener {
+ viewModel.handle(LocationSharingAction.ZoomToUserLocation)
+ }
+ }
+
+ private fun handleZoomToUserLocationEvent(event: LocationSharingViewEvents.ZoomToUserLocation) {
+ views.mapView.zoomToLocation(event.userLocation.latitude, event.userLocation.longitude)
+ }
+
+ private fun initOptionsPicker() {
+ // set no option at start
+ views.shareLocationOptionsPicker.render()
+ views.shareLocationOptionsPicker.optionPinned.debouncedClicks {
+ val targetLocation = views.mapView.getLocationOfMapCenter()
+ viewModel.handle(LocationSharingAction.PinnedLocationSharing(targetLocation))
+ }
+ views.shareLocationOptionsPicker.optionUserCurrent.debouncedClicks {
+ viewModel.handle(LocationSharingAction.CurrentUserLocationSharing)
+ }
+ views.shareLocationOptionsPicker.optionUserLive.debouncedClicks {
+ tryStartLiveLocationSharing()
+ }
+ }
+
+ private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
+ if (allGranted && checkPermissions(PERMISSIONS_FOR_BACKGROUND_LOCATION_SHARING, requireActivity(), backgroundLocationResultLauncher)) {
+ startLiveLocationSharing()
+ } else if (deniedPermanently) {
+ handleMissingBackgroundLocationPermission()
+ }
+ }
+
+ private val backgroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
+ if (allGranted) {
+ startLiveLocationSharing()
+ } else if (deniedPermanently) {
+ handleMissingBackgroundLocationPermission()
+ }
+ }
+
+ private fun tryStartLiveLocationSharing() {
+ // we need to re-check foreground location to be sure it has not changed after landing on this screen
+ if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher) &&
+ checkPermissions(
+ PERMISSIONS_FOR_BACKGROUND_LOCATION_SHARING,
+ requireActivity(),
+ backgroundLocationResultLauncher,
+ R.string.location_in_background_missing_permission_dialog_content
+ )) {
+ startLiveLocationSharing()
+ }
+ }
+
+ private fun startLiveLocationSharing() {
+ viewModel.handle(LocationSharingAction.StartLiveLocationSharing)
+ }
+
+ private fun updateMap(state: LocationSharingViewState) {
+ // first, update the options view
+ val options: Set = when (state.areTargetAndUserLocationEqual) {
+ true -> {
+ if (BuildConfig.ENABLE_LIVE_LOCATION_SHARING) {
+ setOf(LocationSharingOption.USER_CURRENT, LocationSharingOption.USER_LIVE)
+ } else {
+ setOf(LocationSharingOption.USER_CURRENT)
+ }
+ }
+ false -> setOf(LocationSharingOption.PINNED)
+ else -> emptySet()
+ }
+ views.shareLocationOptionsPicker.render(options)
+
+ // then, update the map using the height of the options view after it has been rendered
+ views.shareLocationOptionsPicker.post {
+ val mapState = state
+ .toMapState()
+ .copy(logoMarginBottom = views.shareLocationOptionsPicker.height)
+ views.mapView.render(mapState)
+ }
+ }
+
+ private fun updateUserAvatar(userItem: MatrixItem.UserItem?) {
+ userItem?.takeUnless { hasRenderedUserAvatar }
+ ?.let {
+ hasRenderedUserAvatar = true
+ avatarRenderer.render(it, views.shareLocationOptionsPicker.optionUserCurrent.iconView)
+ val tintColor = matrixItemColorProvider.getColor(it)
+ views.shareLocationOptionsPicker.optionUserCurrent.setIconBackgroundTint(tintColor)
+ }
+ }
+
+ private fun updateLocationTargetPin(drawable: Drawable) {
+ views.shareLocationPin.setImageDrawable(drawable)
}
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingNavigator.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingNavigator.kt
new file mode 100644
index 0000000000..8927da9239
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingNavigator.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location
+
+interface LocationSharingNavigator {
+ var goingToAppSettings: Boolean
+ fun quit()
+ fun goToAppSettings()
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt
index 743daaf5e0..8d31db1119 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt
@@ -21,4 +21,5 @@ import im.vector.app.core.platform.VectorViewEvents
sealed class LocationSharingViewEvents : VectorViewEvents {
object Close : LocationSharingViewEvents()
object LocationNotAvailableError : LocationSharingViewEvents()
+ data class ZoomToUserLocation(val userLocation: LocationData) : LocationSharingViewEvents()
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
index f4e1fd0281..639666e63f 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
@@ -16,6 +16,7 @@
package im.vector.app.features.location
+import android.graphics.drawable.Drawable
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -25,17 +26,37 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
+import im.vector.app.features.location.domain.usecase.CompareLocationsUseCase
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.lastOrNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.sample
+import kotlinx.coroutines.launch
+import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.util.toMatrixItem
+import timber.log.Timber
+
+/**
+ * Sampling period to compare target location and user location.
+ */
+private const val TARGET_LOCATION_CHANGE_SAMPLING_PERIOD_IN_MS = 100L
class LocationSharingViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationSharingViewState,
private val locationTracker: LocationTracker,
private val locationPinProvider: LocationPinProvider,
- private val session: Session
+ private val session: Session,
+ private val compareLocationsUseCase: CompareLocationsUseCase
) : VectorViewModel(initialState), LocationTracker.Callback {
private val room = session.getRoom(initialState.roomId)!!
+ private val locationTargetFlow = MutableSharedFlow()
+
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory {
override fun create(initialState: LocationSharingViewState): LocationSharingViewModel
@@ -45,19 +66,50 @@ class LocationSharingViewModel @AssistedInject constructor(
init {
locationTracker.start(this)
- createPin()
+ setUserItem()
+ updatePin()
+ compareTargetAndUserLocation()
}
- private fun createPin() {
- locationPinProvider.create(session.myUserId) {
- setState {
- copy(
- pinDrawable = it
- )
+ private fun setUserItem() {
+ setState { copy(userItem = session.getUser(session.myUserId)?.toMatrixItem()) }
+ }
+
+ private fun updatePin(isUserPin: Boolean? = true) {
+ if (isUserPin.orFalse()) {
+ locationPinProvider.create(userId = session.myUserId) {
+ updatePinDrawableInState(it)
+ }
+ } else {
+ locationPinProvider.create(userId = null) {
+ updatePinDrawableInState(it)
}
}
}
+ private fun updatePinDrawableInState(drawable: Drawable) {
+ setState {
+ copy(
+ locationTargetDrawable = drawable
+ )
+ }
+ }
+
+ private fun compareTargetAndUserLocation() {
+ locationTargetFlow
+ .sample(TARGET_LOCATION_CHANGE_SAMPLING_PERIOD_IN_MS)
+ .map { compareTargetLocation(it) }
+ .distinctUntilChanged()
+ .onEach { setState { copy(areTargetAndUserLocationEqual = it) } }
+ .onEach { updatePin(isUserPin = it) }
+ .launchIn(viewModelScope)
+ }
+
+ private suspend fun compareTargetLocation(targetLocation: LocationData): Boolean? {
+ return awaitState().lastKnownUserLocation
+ ?.let { userLocation -> compareLocationsUseCase.execute(userLocation, targetLocation) }
+ }
+
override fun onCleared() {
super.onCleared()
locationTracker.stop()
@@ -65,16 +117,29 @@ class LocationSharingViewModel @AssistedInject constructor(
override fun handle(action: LocationSharingAction) {
when (action) {
- LocationSharingAction.OnShareLocation -> handleShareLocation()
+ LocationSharingAction.CurrentUserLocationSharing -> handleCurrentUserLocationSharingAction()
+ is LocationSharingAction.PinnedLocationSharing -> handlePinnedLocationSharingAction(action)
+ is LocationSharingAction.LocationTargetChange -> handleLocationTargetChangeAction(action)
+ LocationSharingAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
+ LocationSharingAction.StartLiveLocationSharing -> handleStartLiveLocationSharingAction()
}.exhaustive
}
- private fun handleShareLocation() = withState { state ->
- state.lastKnownLocation?.let { location ->
+ private fun handleCurrentUserLocationSharingAction() = withState { state ->
+ shareLocation(state.lastKnownUserLocation, isUserLocation = true)
+ }
+
+ private fun handlePinnedLocationSharingAction(action: LocationSharingAction.PinnedLocationSharing) {
+ shareLocation(action.locationData, isUserLocation = false)
+ }
+
+ private fun shareLocation(locationData: LocationData?, isUserLocation: Boolean) {
+ locationData?.let { location ->
room.sendLocation(
latitude = location.latitude,
longitude = location.longitude,
- uncertainty = location.uncertainty
+ uncertainty = location.uncertainty,
+ isUserLocation = isUserLocation
)
_viewEvents.post(LocationSharingViewEvents.Close)
} ?: run {
@@ -82,9 +147,32 @@ class LocationSharingViewModel @AssistedInject constructor(
}
}
+ private fun handleLocationTargetChangeAction(action: LocationSharingAction.LocationTargetChange) {
+ viewModelScope.launch {
+ locationTargetFlow.emit(action.locationData)
+ }
+ }
+
+ private fun handleZoomToUserLocationAction() = withState { state ->
+ state.lastKnownUserLocation?.let { location ->
+ _viewEvents.post(LocationSharingViewEvents.ZoomToUserLocation(location))
+ }
+ }
+
+ private fun handleStartLiveLocationSharingAction() {
+ // TODO start sharing live location and update view state
+ Timber.d("live location sharing started")
+ }
+
override fun onLocationUpdate(locationData: LocationData) {
setState {
- copy(lastKnownLocation = locationData)
+ copy(lastKnownUserLocation = locationData)
+ }
+ viewModelScope.launch {
+ // recompute location comparison using last received target location
+ locationTargetFlow.lastOrNull()?.let {
+ locationTargetFlow.emit(it)
+ }
}
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
index a9a24094eb..ee5ba402e2 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
@@ -20,6 +20,8 @@ import android.graphics.drawable.Drawable
import androidx.annotation.StringRes
import com.airbnb.mvrx.MavericksState
import im.vector.app.R
+import org.matrix.android.sdk.api.extensions.orTrue
+import org.matrix.android.sdk.api.util.MatrixItem
enum class LocationSharingMode(@StringRes val titleRes: Int) {
STATIC_SHARING(R.string.location_activity_title_static_sharing),
@@ -29,8 +31,10 @@ enum class LocationSharingMode(@StringRes val titleRes: Int) {
data class LocationSharingViewState(
val roomId: String,
val mode: LocationSharingMode,
- val lastKnownLocation: LocationData? = null,
- val pinDrawable: Drawable? = null
+ val userItem: MatrixItem.UserItem? = null,
+ val areTargetAndUserLocationEqual: Boolean? = null,
+ val lastKnownUserLocation: LocationData? = null,
+ val locationTargetDrawable: Drawable? = null
) : MavericksState {
constructor(locationSharingArgs: LocationSharingArgs) : this(
@@ -41,7 +45,9 @@ data class LocationSharingViewState(
fun LocationSharingViewState.toMapState() = MapState(
zoomOnlyOnce = true,
- pinLocationData = lastKnownLocation,
+ userLocationData = lastKnownUserLocation,
pinId = DEFAULT_PIN_ID,
- pinDrawable = pinDrawable
+ pinDrawable = null,
+ // show the map pin only when target location and user location are not equal
+ showPin = areTargetAndUserLocationEqual.orTrue().not()
)
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTargetChangeListener.kt b/vector/src/main/java/im/vector/app/features/location/LocationTargetChangeListener.kt
new file mode 100644
index 0000000000..07e3afb399
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/LocationTargetChangeListener.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location
+
+interface LocationTargetChangeListener {
+ fun onLocationTargetChange(target: LocationData)
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/MapState.kt b/vector/src/main/java/im/vector/app/features/location/MapState.kt
index d001457e4f..c4325291a8 100644
--- a/vector/src/main/java/im/vector/app/features/location/MapState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/MapState.kt
@@ -17,10 +17,13 @@
package im.vector.app.features.location
import android.graphics.drawable.Drawable
+import androidx.annotation.Px
data class MapState(
val zoomOnlyOnce: Boolean,
- val pinLocationData: LocationData? = null,
+ val userLocationData: LocationData? = null,
val pinId: String,
- val pinDrawable: Drawable? = null
+ val pinDrawable: Drawable? = null,
+ val showPin: Boolean = true,
+ @Px val logoMarginBottom: Int = 0
)
diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
index dd80f701f6..e3206e231d 100644
--- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
+++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
@@ -17,7 +17,14 @@
package im.vector.app.features.location
import android.content.Context
+import android.content.res.TypedArray
import android.util.AttributeSet
+import android.view.Gravity
+import android.widget.ImageView
+import androidx.core.content.ContextCompat
+import androidx.core.view.marginBottom
+import androidx.core.view.marginTop
+import androidx.core.view.updateLayoutParams
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.maps.MapView
@@ -26,6 +33,7 @@ import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property
+import im.vector.app.R
import timber.log.Timber
class MapTilerMapView @JvmOverloads constructor(
@@ -42,24 +50,100 @@ class MapTilerMapView @JvmOverloads constructor(
val style: Style
)
+ private val userLocationDrawable by lazy {
+ ContextCompat.getDrawable(context, R.drawable.ic_location_user)
+ }
+ val locateButton by lazy { createLocateButton() }
private var mapRefs: MapRefs? = null
private var initZoomDone = false
+ private var showLocationButton = false
+
+ init {
+ context.theme.obtainStyledAttributes(
+ attrs,
+ R.styleable.MapTilerMapView,
+ 0,
+ 0
+ ).run {
+ try {
+ setLocateButtonVisibility(this)
+ } finally {
+ recycle()
+ }
+ }
+ }
+
+ private fun setLocateButtonVisibility(typedArray: TypedArray) {
+ showLocationButton = typedArray.getBoolean(R.styleable.MapTilerMapView_showLocateButton, false)
+ }
/**
* For location fragments
*/
- fun initialize(url: String) {
+ fun initialize(
+ url: String,
+ locationTargetChangeListener: LocationTargetChangeListener? = null
+ ) {
Timber.d("## Location: initialize")
getMapAsync { map ->
- map.setStyle(url) { style ->
- mapRefs = MapRefs(
- map,
- SymbolManager(this, map, style),
- style
- )
- pendingState?.let { render(it) }
- pendingState = null
+ initMapStyle(map, url)
+ initLocateButton(map)
+ notifyLocationOfMapCenter(locationTargetChangeListener)
+ listenCameraMove(map, locationTargetChangeListener)
+ }
+ }
+
+ private fun initMapStyle(map: MapboxMap, url: String) {
+ map.setStyle(url) { style ->
+ mapRefs = MapRefs(
+ map,
+ SymbolManager(this, map, style),
+ style
+ )
+ pendingState?.let { render(it) }
+ pendingState = null
+ }
+ }
+
+ private fun initLocateButton(map: MapboxMap) {
+ if (showLocationButton) {
+ addView(locateButton)
+ adjustCompassButton(map)
+ }
+ }
+
+ private fun createLocateButton(): ImageView =
+ ImageView(context).apply {
+ setImageDrawable(ContextCompat.getDrawable(context, R.drawable.btn_locate))
+ contentDescription = context.getString(R.string.a11y_location_share_locate_button)
+ layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
+ updateLayoutParams {
+ val marginHorizontal = context.resources.getDimensionPixelOffset(R.dimen.location_sharing_locate_button_margin_horizontal)
+ val marginVertical = context.resources.getDimensionPixelOffset(R.dimen.location_sharing_locate_button_margin_vertical)
+ setMargins(marginHorizontal, marginVertical, marginHorizontal, marginVertical)
+ }
+ updateLayoutParams {
+ gravity = Gravity.TOP or Gravity.END
+ }
}
+
+ private fun adjustCompassButton(map: MapboxMap) {
+ locateButton.post {
+ val marginTop = locateButton.height + locateButton.marginTop + locateButton.marginBottom
+ val marginRight = context.resources.getDimensionPixelOffset(R.dimen.location_sharing_compass_button_margin_horizontal)
+ map.uiSettings.setCompassMargins(0, marginTop, marginRight, 0)
+ }
+ }
+
+ private fun listenCameraMove(map: MapboxMap, locationTargetChangeListener: LocationTargetChangeListener?) {
+ map.addOnCameraMoveListener {
+ notifyLocationOfMapCenter(locationTargetChangeListener)
+ }
+ }
+
+ private fun notifyLocationOfMapCenter(locationTargetChangeListener: LocationTargetChangeListener?) {
+ getLocationOfMapCenter()?.let { target ->
+ locationTargetChangeListener?.onLocationTargetChange(target)
}
}
@@ -68,34 +152,48 @@ class MapTilerMapView @JvmOverloads constructor(
pendingState = state
}
- state.pinDrawable?.let { pinDrawable ->
+ safeMapRefs.map.uiSettings.setLogoMargins(0, 0, 0, state.logoMarginBottom)
+
+ val pinDrawable = state.pinDrawable ?: userLocationDrawable
+ pinDrawable?.let { drawable ->
if (!safeMapRefs.style.isFullyLoaded ||
safeMapRefs.style.getImage(state.pinId) == null) {
- safeMapRefs.style.addImage(state.pinId, pinDrawable)
+ safeMapRefs.style.addImage(state.pinId, drawable)
}
}
- state.pinLocationData?.let { locationData ->
+ state.userLocationData?.let { locationData ->
if (!initZoomDone || !state.zoomOnlyOnce) {
zoomToLocation(locationData.latitude, locationData.longitude)
initZoomDone = true
}
safeMapRefs.symbolManager.deleteAll()
- safeMapRefs.symbolManager.create(
- SymbolOptions()
- .withLatLng(LatLng(locationData.latitude, locationData.longitude))
- .withIconImage(state.pinId)
- .withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
- )
+ if (pinDrawable != null && state.showPin) {
+ safeMapRefs.symbolManager.create(
+ SymbolOptions()
+ .withLatLng(LatLng(locationData.latitude, locationData.longitude))
+ .withIconImage(state.pinId)
+ .withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
+ )
+ }
}
}
- private fun zoomToLocation(latitude: Double, longitude: Double) {
+ fun zoomToLocation(latitude: Double, longitude: Double) {
Timber.d("## Location: zoomToLocation")
mapRefs?.map?.cameraPosition = CameraPosition.Builder()
.target(LatLng(latitude, longitude))
.zoom(INITIAL_MAP_ZOOM_IN_PREVIEW)
.build()
}
+
+ fun getLocationOfMapCenter(): LocationData? =
+ mapRefs?.map?.cameraPosition?.target?.let { target ->
+ LocationData(
+ latitude = target.latitude,
+ longitude = target.longitude,
+ uncertainty = null
+ )
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt b/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt
index adb5c27a02..3e4e16861e 100644
--- a/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt
+++ b/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt
@@ -39,7 +39,7 @@ class UrlMapProvider @Inject constructor(
suspend fun getMapUrl(): String {
val upstreamMapUrl = tryOrNull { rawService.getElementWellknown(session.sessionParams) }
- ?.mapTileServerConfig
+ ?.getBestMapTileServerConfig()
?.mapStyleUrl
return upstreamMapUrl ?: fallbackMapUrl
}
diff --git a/vector/src/main/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCase.kt b/vector/src/main/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCase.kt
new file mode 100644
index 0000000000..91738541be
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCase.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location.domain.usecase
+
+import com.mapbox.mapboxsdk.geometry.LatLng
+import im.vector.app.features.location.LocationData
+import kotlinx.coroutines.withContext
+import org.matrix.android.sdk.api.session.Session
+import javax.inject.Inject
+
+/**
+ * Threshold in meters to consider 2 locations as equal.
+ */
+private const val SAME_LOCATION_THRESHOLD_IN_METERS = 5
+
+/**
+ * Use case to check if 2 locations can be considered as equal.
+ */
+class CompareLocationsUseCase @Inject constructor(
+ private val session: Session
+) {
+
+ /**
+ * Compare the 2 given locations.
+ * @return true when they are really close and could be considered as the same location, false otherwise
+ */
+ suspend fun execute(location1: LocationData, location2: LocationData): Boolean =
+ withContext(session.coroutineDispatchers.io) {
+ val loc1 = LatLng(location1.latitude, location1.longitude)
+ val loc2 = LatLng(location2.latitude, location2.longitude)
+ val distance = loc1.distanceTo(loc2)
+ distance <= SAME_LOCATION_THRESHOLD_IN_METERS
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveStatusView.kt b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveStatusView.kt
new file mode 100644
index 0000000000..a4c58c9e5b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveStatusView.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location.live
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.Button
+import androidx.constraintlayout.widget.ConstraintLayout
+import im.vector.app.databinding.ViewLocationLiveStatusBinding
+
+class LocationLiveStatusView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+
+ private val binding = ViewLocationLiveStatusBinding.inflate(
+ LayoutInflater.from(context),
+ this
+ )
+
+ val stopButton: Button
+ get() = binding.locationLiveStatusStop
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOption.kt b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOption.kt
new file mode 100644
index 0000000000..ebf9bde5f6
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOption.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location.option
+
+enum class LocationSharingOption {
+ /**
+ * Current user's location.
+ */
+ USER_CURRENT,
+
+ /**
+ * User's location during a certain amount of time.
+ */
+ USER_LIVE,
+
+ /**
+ * Static location pinned by the user.
+ */
+ PINNED
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionPickerView.kt b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionPickerView.kt
new file mode 100644
index 0000000000..8a603a1a56
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionPickerView.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location.option
+
+import android.content.Context
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import im.vector.app.R
+import im.vector.app.databinding.ViewLocationSharingOptionPickerBinding
+
+/**
+ * Custom view to display the location sharing option picker.
+ */
+class LocationSharingOptionPickerView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+
+ val optionPinned: LocationSharingOptionView
+ get() = binding.locationSharingOptionPinned
+
+ val optionUserCurrent: LocationSharingOptionView
+ get() = binding.locationSharingOptionUserCurrent
+
+ val optionUserLive: LocationSharingOptionView
+ get() = binding.locationSharingOptionUserLive
+
+ private val divider1: View
+ get() = binding.locationSharingOptionsDivider1
+
+ private val divider2: View
+ get() = binding.locationSharingOptionsDivider2
+
+ private val binding = ViewLocationSharingOptionPickerBinding.inflate(
+ LayoutInflater.from(context),
+ this
+ )
+
+ init {
+ applyBackground()
+ }
+
+ fun render(options: Set = emptySet()) {
+ val optionsNumber = options.toSet().size
+ val isPinnedVisible = options.contains(LocationSharingOption.PINNED)
+ val isUserCurrentVisible = options.contains(LocationSharingOption.USER_CURRENT)
+ val isUserLiveVisible = options.contains(LocationSharingOption.USER_LIVE)
+
+ optionPinned.isVisible = isPinnedVisible
+ divider1.isVisible = isPinnedVisible && optionsNumber > 1
+ optionUserCurrent.isVisible = isUserCurrentVisible
+ divider2.isVisible = isUserCurrentVisible && isUserLiveVisible
+ optionUserLive.isVisible = isUserLiveVisible
+ }
+
+ private fun applyBackground() {
+ val outValue = TypedValue()
+ context.theme.resolveAttribute(
+ R.attr.colorSurface,
+ outValue,
+ true
+ )
+ binding.root.background = ContextCompat.getDrawable(
+ context,
+ outValue.resourceId
+ )
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionView.kt b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionView.kt
new file mode 100644
index 0000000000..d11ff00261
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionView.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location.option
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.ImageView
+import androidx.annotation.ColorInt
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.core.view.setPadding
+import im.vector.app.R
+import im.vector.app.core.extensions.tintBackground
+import im.vector.app.databinding.ViewLocationSharingOptionBinding
+
+/**
+ * Custom view to display a location sharing option.
+ */
+class LocationSharingOptionView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+
+ val iconView: ImageView
+ get() = binding.shareLocationOptionIcon
+
+ private val binding = ViewLocationSharingOptionBinding.inflate(
+ LayoutInflater.from(context),
+ this
+ )
+
+ init {
+ context.theme.obtainStyledAttributes(
+ attrs,
+ R.styleable.LocationSharingOptionView,
+ 0,
+ 0
+ ).run {
+ try {
+ setIcon(this)
+ setTitle(this)
+ } finally {
+ recycle()
+ }
+ }
+ }
+
+ fun setIconBackgroundTint(@ColorInt color: Int) {
+ binding.shareLocationOptionIcon.tintBackground(color)
+ }
+
+ private fun setIcon(typedArray: TypedArray) {
+ val icon = typedArray.getDrawable(R.styleable.LocationSharingOptionView_locShareIcon)
+ val background = typedArray.getDrawable(R.styleable.LocationSharingOptionView_locShareIconBackground)
+ val backgroundTint = typedArray.getColor(
+ R.styleable.LocationSharingOptionView_locShareIconBackgroundTint,
+ ContextCompat.getColor(context, android.R.color.transparent)
+ )
+ val padding = typedArray.getDimensionPixelOffset(
+ R.styleable.LocationSharingOptionView_locShareIconPadding,
+ context.resources.getDimensionPixelOffset(R.dimen.location_sharing_option_default_padding)
+ )
+ val description = typedArray.getString(R.styleable.LocationSharingOptionView_locShareIconDescription)
+
+ iconView.setImageDrawable(icon)
+ iconView.background = background
+ iconView.tintBackground(backgroundTint)
+ iconView.setPadding(padding)
+ iconView.contentDescription = description
+ }
+
+ private fun setTitle(typedArray: TypedArray) {
+ val title = typedArray.getString(R.styleable.LocationSharingOptionView_locShareTitle)
+ binding.shareLocationOptionTitle.text = title
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt b/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt
index 1e0a3a2ad9..781a176550 100644
--- a/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt
+++ b/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt
@@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
+import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
@@ -52,7 +53,10 @@ class RoomEventsAttachmentProvider(
override fun getAttachmentInfoAt(position: Int): AttachmentInfo {
return getItem(position).let {
- val content = it.root.getClearContent().toModel() as? MessageWithAttachmentContent
+ val clearContent = it.root.getClearContent()
+ val content = clearContent.toModel()
+ ?: clearContent.toModel()
+ as? MessageWithAttachmentContent
if (content is MessageImageContent) {
val data = ImageContentRenderer.Data(
eventId = it.eventId,
@@ -66,6 +70,33 @@ class RoomEventsAttachmentProvider(
height = null,
allowNonMxcUrls = it.root.sendState.isSending()
+ )
+ if (content.mimeType == MimeTypes.Gif) {
+ AttachmentInfo.AnimatedImage(
+ uid = it.eventId,
+ url = content.url ?: "",
+ data = data
+ )
+ } else {
+ AttachmentInfo.Image(
+ uid = it.eventId,
+ url = content.url ?: "",
+ data = data
+ )
+ }
+ } else if (content is MessageStickerContent) {
+ val data = ImageContentRenderer.Data(
+ eventId = it.eventId,
+ filename = content.body,
+ mimeType = content.mimeType,
+ url = content.getFileUrl(),
+ elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
+ maxHeight = -1,
+ maxWidth = -1,
+ width = null,
+ height = null,
+ allowNonMxcUrls = false
+
)
if (content.mimeType == MimeTypes.Gif) {
AttachmentInfo.AnimatedImage(
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
index ec034173fc..3c9b985df5 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
@@ -31,6 +31,7 @@ import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isEdition
import org.matrix.android.sdk.api.session.events.model.isImageMessage
+import org.matrix.android.sdk.api.session.events.model.supportsNotification
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
@@ -94,7 +95,7 @@ class NotifiableEventResolver @Inject constructor(
}
suspend fun resolveInMemoryEvent(session: Session, event: Event, canBeReplaced: Boolean): NotifiableEvent? {
- if (event.getClearType() != EventType.MESSAGE) return null
+ if (!event.supportsNotification()) return null
// Ignore message edition
if (event.isEdition()) return null
@@ -153,7 +154,8 @@ class NotifiableEventResolver @Inject constructor(
event.attemptToDecryptIfNeeded(session)
// only convert encrypted messages to NotifiableMessageEvents
when (event.root.getClearType()) {
- EventType.MESSAGE -> {
+ EventType.MESSAGE,
+ in EventType.POLL_START -> {
val body = displayableEventFormatter.format(event, isDm = room.roomSummary()?.isDirect.orFalse(), appendAuthor = false).toString()
val roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
@@ -185,12 +187,12 @@ class NotifiableEventResolver @Inject constructor(
soundName = null
)
}
- else -> null
+ else -> null
}
}
}
- private fun TimelineEvent.attemptToDecryptIfNeeded(session: Session) {
+ private suspend fun TimelineEvent.attemptToDecryptIfNeeded(session: Session) {
if (root.isEncrypted() && root.mxDecryptionResult == null) {
// TODO use a global event decryptor? attache to session and that listen to new sessionId?
// for now decrypt sync
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt
index b35c110892..7fa75d1544 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt
@@ -22,62 +22,49 @@ import im.vector.app.features.login.LoginConfig
import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode
import org.matrix.android.sdk.api.auth.data.Credentials
-import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.internal.network.ssl.Fingerprint
-sealed class OnboardingAction : VectorViewModelAction {
- data class OnGetStarted(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction()
- data class OnIAlreadyHaveAnAccount(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction()
+sealed interface OnboardingAction : VectorViewModelAction {
+ data class OnGetStarted(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction
+ data class OnIAlreadyHaveAnAccount(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction
- data class UpdateServerType(val serverType: ServerType) : OnboardingAction()
- data class UpdateHomeServer(val homeServerUrl: String) : OnboardingAction()
- data class UpdateUseCase(val useCase: FtueUseCase) : OnboardingAction()
- object ResetUseCase : OnboardingAction()
- data class UpdateSignMode(val signMode: SignMode) : OnboardingAction()
- data class LoginWithToken(val loginToken: String) : OnboardingAction()
- data class WebLoginSuccess(val credentials: Credentials) : OnboardingAction()
- data class InitWith(val loginConfig: LoginConfig?) : OnboardingAction()
- data class ResetPassword(val email: String, val newPassword: String) : OnboardingAction()
- object ResetPasswordMailConfirmed : OnboardingAction()
+ data class UpdateServerType(val serverType: ServerType) : OnboardingAction
+ data class UpdateHomeServer(val homeServerUrl: String) : OnboardingAction
+ data class UpdateUseCase(val useCase: FtueUseCase) : OnboardingAction
+ object ResetUseCase : OnboardingAction
+ data class UpdateSignMode(val signMode: SignMode) : OnboardingAction
+ data class LoginWithToken(val loginToken: String) : OnboardingAction
+ data class WebLoginSuccess(val credentials: Credentials) : OnboardingAction
+ data class InitWith(val loginConfig: LoginConfig?) : OnboardingAction
+ data class ResetPassword(val email: String, val newPassword: String) : OnboardingAction
+ object ResetPasswordMailConfirmed : OnboardingAction
// Login or Register, depending on the signMode
- data class LoginOrRegister(val username: String, val password: String, val initialDeviceName: String) : OnboardingAction()
+ data class LoginOrRegister(val username: String, val password: String, val initialDeviceName: String) : OnboardingAction
+ object StopEmailValidationCheck : OnboardingAction
- // Register actions
- open class RegisterAction : OnboardingAction()
-
- data class AddThreePid(val threePid: RegisterThreePid) : RegisterAction()
- object SendAgainThreePid : RegisterAction()
-
- // TODO Confirm Email (from link in the email, open in the phone, intercepted by the app)
- data class ValidateThreePid(val code: String) : RegisterAction()
-
- data class CheckIfEmailHasBeenValidated(val delayMillis: Long) : RegisterAction()
- object StopEmailValidationCheck : RegisterAction()
-
- data class CaptchaDone(val captchaResponse: String) : RegisterAction()
- object AcceptTerms : RegisterAction()
- object RegisterDummy : RegisterAction()
+ data class PostRegisterAction(val registerAction: RegisterAction) : OnboardingAction
// Reset actions
- open class ResetAction : OnboardingAction()
+ sealed interface ResetAction : OnboardingAction
- object ResetHomeServerType : ResetAction()
- object ResetHomeServerUrl : ResetAction()
- object ResetSignMode : ResetAction()
- object ResetLogin : ResetAction()
- object ResetResetPassword : ResetAction()
+ object ResetHomeServerType : ResetAction
+ object ResetHomeServerUrl : ResetAction
+ object ResetSignMode : ResetAction
+ object ResetLogin : ResetAction
+ object ResetResetPassword : ResetAction
// Homeserver history
- object ClearHomeServerHistory : OnboardingAction()
+ object ClearHomeServerHistory : OnboardingAction
- data class PostViewEvent(val viewEvent: OnboardingViewEvents) : OnboardingAction()
+ data class PostViewEvent(val viewEvent: OnboardingViewEvents) : OnboardingAction
- data class UserAcceptCertificate(val fingerprint: Fingerprint) : OnboardingAction()
+ data class UserAcceptCertificate(val fingerprint: Fingerprint) : OnboardingAction
- data class UpdateDisplayName(val displayName: String) : OnboardingAction()
- object UpdateDisplayNameSkipped : OnboardingAction()
- data class ProfilePictureSelected(val uri: Uri) : OnboardingAction()
- object SaveSelectedProfilePicture : OnboardingAction()
- object UpdateProfilePictureSkipped : OnboardingAction()
+ object PersonalizeProfile : OnboardingAction
+ data class UpdateDisplayName(val displayName: String) : OnboardingAction
+ object UpdateDisplayNameSkipped : OnboardingAction
+ data class ProfilePictureSelected(val uri: Uri) : OnboardingAction
+ object SaveSelectedProfilePicture : OnboardingAction
+ object UpdateProfilePictureSkipped : OnboardingAction
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt
index 8a09879b15..82ee48411d 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt
@@ -51,9 +51,8 @@ sealed class OnboardingViewEvents : VectorViewEvents {
object OnAccountCreated : OnboardingViewEvents()
object OnAccountSignedIn : OnboardingViewEvents()
object OnTakeMeHome : OnboardingViewEvents()
- object OnPersonalizeProfile : OnboardingViewEvents()
- object OnDisplayNameUpdated : OnboardingViewEvents()
- object OnDisplayNameSkipped : OnboardingViewEvents()
+ object OnChooseDisplayName : OnboardingViewEvents()
+ object OnChooseProfilePicture : OnboardingViewEvents()
object OnPersonalizationComplete : OnboardingViewEvents()
object OnBack : OnboardingViewEvents()
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
index 413745f98c..64fa3f0eee 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
@@ -48,6 +48,7 @@ import im.vector.app.features.login.ReAuthHelper
import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.auth.AuthenticationService
@@ -82,6 +83,7 @@ class OnboardingViewModel @AssistedInject constructor(
private val vectorFeatures: VectorFeatures,
private val analyticsTracker: AnalyticsTracker,
private val uriFilenameResolver: UriFilenameResolver,
+ private val registrationActionHandler: RegistrationActionHandler,
private val vectorOverrides: VectorOverrides
) : VectorViewModel(initialState) {
@@ -115,16 +117,16 @@ class OnboardingViewModel @AssistedInject constructor(
private val matrixOrgUrl = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash()
+ private val registrationWizard: RegistrationWizard
+ get() = authenticationService.getRegistrationWizard()
+
val currentThreePid: String?
- get() = registrationWizard?.currentThreePid
+ get() = registrationWizard.currentThreePid
// True when login and password has been sent with success to the homeserver
val isRegistrationStarted: Boolean
get() = authenticationService.isRegistrationStarted
- private val registrationWizard: RegistrationWizard?
- get() = authenticationService.getRegistrationWizard()
-
private val loginWizard: LoginWizard?
get() = authenticationService.getLoginWizard()
@@ -152,16 +154,18 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action)
is OnboardingAction.ResetPassword -> handleResetPassword(action)
is OnboardingAction.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed()
- is OnboardingAction.RegisterAction -> handleRegisterAction(action)
+ is OnboardingAction.PostRegisterAction -> handleRegisterAction(action.registerAction)
is OnboardingAction.ResetAction -> handleResetAction(action)
is OnboardingAction.UserAcceptCertificate -> handleUserAcceptCertificate(action)
OnboardingAction.ClearHomeServerHistory -> handleClearHomeServerHistory()
- is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
is OnboardingAction.UpdateDisplayName -> updateDisplayName(action.displayName)
- OnboardingAction.UpdateDisplayNameSkipped -> _viewEvents.post(OnboardingViewEvents.OnDisplayNameSkipped)
- OnboardingAction.UpdateProfilePictureSkipped -> _viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
+ OnboardingAction.UpdateDisplayNameSkipped -> handleDisplayNameStepComplete()
+ OnboardingAction.UpdateProfilePictureSkipped -> completePersonalization()
+ OnboardingAction.PersonalizeProfile -> handlePersonalizeProfile()
is OnboardingAction.ProfilePictureSelected -> handleProfilePictureSelected(action)
OnboardingAction.SaveSelectedProfilePicture -> updateProfilePicture()
+ is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
+ OnboardingAction.StopEmailValidationCheck -> cancelWaitForEmailValidation()
}.exhaustive
}
@@ -264,131 +268,41 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
- private fun handleRegisterAction(action: OnboardingAction.RegisterAction) {
- when (action) {
- is OnboardingAction.CaptchaDone -> handleCaptchaDone(action)
- is OnboardingAction.AcceptTerms -> handleAcceptTerms()
- is OnboardingAction.RegisterDummy -> handleRegisterDummy()
- is OnboardingAction.AddThreePid -> handleAddThreePid(action)
- is OnboardingAction.SendAgainThreePid -> handleSendAgainThreePid()
- is OnboardingAction.ValidateThreePid -> handleValidateThreePid(action)
- is OnboardingAction.CheckIfEmailHasBeenValidated -> handleCheckIfEmailHasBeenValidated(action)
- is OnboardingAction.StopEmailValidationCheck -> handleStopEmailValidationCheck()
- }
- }
-
- private fun handleCheckIfEmailHasBeenValidated(action: OnboardingAction.CheckIfEmailHasBeenValidated) {
- // We do not want the common progress bar to be displayed, so we do not change asyncRegistration value in the state
- currentJob = executeRegistrationStep(withLoading = false) {
- it.checkIfEmailHasBeenValidated(action.delayMillis)
- }
- }
-
- private fun handleStopEmailValidationCheck() {
- currentJob = null
- }
-
- private fun handleValidateThreePid(action: OnboardingAction.ValidateThreePid) {
- currentJob = executeRegistrationStep {
- it.handleValidateThreePid(action.code)
- }
- }
-
- private fun executeRegistrationStep(withLoading: Boolean = true,
- block: suspend (RegistrationWizard) -> RegistrationResult): Job {
- if (withLoading) {
- setState { copy(asyncRegistration = Loading()) }
- }
- return viewModelScope.launch {
- try {
- registrationWizard?.let { block(it) }
- /*
- // Simulate registration disabled
- throw Failure.ServerError(MatrixError(
- code = MatrixError.FORBIDDEN,
- message = "Registration is disabled"
- ), 403))
- */
- } catch (failure: Throwable) {
- if (failure !is CancellationException) {
- _viewEvents.post(OnboardingViewEvents.Failure(failure))
- }
- null
- }
- ?.let { data ->
- when (data) {
- is RegistrationResult.Success -> onSessionCreated(data.session, isAccountCreated = true)
- is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult)
- }
- }
-
- setState {
- copy(
- asyncRegistration = Uninitialized
- )
- }
- }
- }
-
- private fun handleAddThreePid(action: OnboardingAction.AddThreePid) {
- setState { copy(asyncRegistration = Loading()) }
+ private fun handleRegisterAction(action: RegisterAction) {
currentJob = viewModelScope.launch {
- try {
- registrationWizard?.addThreePid(action.threePid)
- } catch (failure: Throwable) {
- _viewEvents.post(OnboardingViewEvents.Failure(failure))
+ if (action.hasLoadingState()) {
+ setState { copy(asyncRegistration = Loading()) }
}
- setState {
- copy(
- asyncRegistration = Uninitialized
- )
- }
- }
- }
-
- private fun handleSendAgainThreePid() {
- setState { copy(asyncRegistration = Loading()) }
- currentJob = viewModelScope.launch {
- try {
- registrationWizard?.sendAgainThreePid()
- } catch (failure: Throwable) {
- _viewEvents.post(OnboardingViewEvents.Failure(failure))
- }
- setState {
- copy(
- asyncRegistration = Uninitialized
- )
- }
- }
- }
-
- private fun handleAcceptTerms() {
- currentJob = executeRegistrationStep {
- it.acceptTerms()
- }
- }
-
- private fun handleRegisterDummy() {
- currentJob = executeRegistrationStep {
- it.dummy()
+ runCatching { registrationActionHandler.handleRegisterAction(registrationWizard, action) }
+ .fold(
+ onSuccess = {
+ when {
+ action.ignoresResult() -> {
+ // do nothing
+ }
+ else -> when (it) {
+ is RegistrationResult.Success -> onSessionCreated(it.session, isAccountCreated = true)
+ is RegistrationResult.FlowResponse -> onFlowResponse(it.flowResult)
+ }
+ }
+ },
+ onFailure = {
+ if (it !is CancellationException) {
+ _viewEvents.post(OnboardingViewEvents.Failure(it))
+ }
+ }
+ )
+ setState { copy(asyncRegistration = Uninitialized) }
}
}
private fun handleRegisterWith(action: OnboardingAction.LoginOrRegister) {
reAuthHelper.data = action.password
- currentJob = executeRegistrationStep {
- it.createAccount(
- action.username,
- action.password,
- action.initialDeviceName
- )
- }
- }
-
- private fun handleCaptchaDone(action: OnboardingAction.CaptchaDone) {
- currentJob = executeRegistrationStep {
- it.performReCaptcha(action.captchaResponse)
- }
+ handleRegisterAction(RegisterAction.CreateAccount(
+ action.username,
+ action.password,
+ action.initialDeviceName
+ ))
}
private fun handleResetAction(action: OnboardingAction.ResetAction) {
@@ -459,7 +373,7 @@ class OnboardingViewModel @AssistedInject constructor(
}
when (action.signMode) {
- SignMode.SignUp -> startRegistrationFlow()
+ SignMode.SignUp -> handleRegisterAction(RegisterAction.StartRegistration)
SignMode.SignIn -> startAuthenticationFlow()
SignMode.SignInWithMatrixId -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignInWithMatrixId))
SignMode.Unknown -> Unit
@@ -497,7 +411,7 @@ class OnboardingViewModel @AssistedInject constructor(
// If there is a pending email validation continue on this step
try {
- if (registrationWizard?.isRegistrationStarted == true) {
+ if (registrationWizard.isRegistrationStarted) {
currentThreePid?.let {
handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnSendEmailSuccess(it)))
}
@@ -664,7 +578,7 @@ class OnboardingViewModel @AssistedInject constructor(
onDirectLoginError(failure)
return
}
- onSessionCreated(data, isAccountCreated = true)
+ onSessionCreated(data, isAccountCreated = false)
}
private fun onDirectLoginError(failure: Throwable) {
@@ -728,12 +642,6 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
- private fun startRegistrationFlow() {
- currentJob = executeRegistrationStep {
- it.getRegistrationFlow()
- }
- }
-
private fun startAuthenticationFlow() {
// Ensure Wizard is ready
loginWizard
@@ -743,8 +651,7 @@ class OnboardingViewModel @AssistedInject constructor(
private fun onFlowResponse(flowResult: FlowResult) {
// If dummy stage is mandatory, and password is already sent, do the dummy stage now
- if (isRegistrationStarted &&
- flowResult.missingStages.any { it is Stage.Dummy && it.mandatory }) {
+ if (isRegistrationStarted && flowResult.missingStages.any { it is Stage.Dummy && it.mandatory }) {
handleRegisterDummy()
} else {
// Notify the user
@@ -752,6 +659,10 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
+ private fun handleRegisterDummy() {
+ handleRegisterAction(RegisterAction.RegisterDummy)
+ }
+
private suspend fun onSessionCreated(session: Session, isAccountCreated: Boolean) {
val state = awaitState()
state.useCase?.let { useCase ->
@@ -762,15 +673,33 @@ class OnboardingViewModel @AssistedInject constructor(
authenticationService.reset()
session.configureAndStart(applicationContext)
- setState {
- copy(
- asyncLoginAction = Success(Unit)
- )
- }
when (isAccountCreated) {
- true -> _viewEvents.post(OnboardingViewEvents.OnAccountCreated)
- false -> _viewEvents.post(OnboardingViewEvents.OnAccountSignedIn)
+ true -> {
+ val personalizationState = createPersonalizationState(session, state)
+ setState {
+ copy(asyncLoginAction = Success(Unit), personalizationState = personalizationState)
+ }
+ _viewEvents.post(OnboardingViewEvents.OnAccountCreated)
+ }
+ false -> {
+ setState { copy(asyncLoginAction = Success(Unit)) }
+ _viewEvents.post(OnboardingViewEvents.OnAccountSignedIn)
+ }
+ }
+ }
+
+ private suspend fun createPersonalizationState(session: Session, state: OnboardingViewState): PersonalizationState {
+ return when {
+ vectorFeatures.isOnboardingPersonalizeEnabled() -> {
+ val homeServerCapabilities = session.getHomeServerCapabilities()
+ val capabilityOverrides = vectorOverrides.forceHomeserverCapabilities?.firstOrNull()
+ state.personalizationState.copy(
+ supportsChangingDisplayName = capabilityOverrides?.canChangeDisplayName ?: homeServerCapabilities.canChangeDisplayName,
+ supportsChangingProfilePicture = capabilityOverrides?.canChangeAvatar ?: homeServerCapabilities.canChangeAvatar
+ )
+ }
+ else -> state.personalizationState
}
}
@@ -910,7 +839,7 @@ class OnboardingViewModel @AssistedInject constructor(
personalizationState = personalizationState.copy(displayName = displayName)
)
}
- _viewEvents.post(OnboardingViewEvents.OnDisplayNameUpdated)
+ handleDisplayNameStepComplete()
} catch (error: Throwable) {
setState { copy(asyncDisplayName = Fail(error)) }
_viewEvents.post(OnboardingViewEvents.Failure(error))
@@ -918,12 +847,37 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
+ private fun handlePersonalizeProfile() {
+ withPersonalisationState {
+ when {
+ it.supportsChangingDisplayName -> _viewEvents.post(OnboardingViewEvents.OnChooseDisplayName)
+ it.supportsChangingProfilePicture -> _viewEvents.post(OnboardingViewEvents.OnChooseProfilePicture)
+ else -> {
+ throw IllegalStateException("It should not be possible to personalize without supporting display name or avatar changing")
+ }
+ }
+ }
+ }
+
+ private fun handleDisplayNameStepComplete() {
+ withPersonalisationState {
+ when {
+ it.supportsChangingProfilePicture -> _viewEvents.post(OnboardingViewEvents.OnChooseProfilePicture)
+ else -> completePersonalization()
+ }
+ }
+ }
+
private fun handleProfilePictureSelected(action: OnboardingAction.ProfilePictureSelected) {
setState {
copy(personalizationState = personalizationState.copy(selectedPictureUri = action.uri))
}
}
+ private fun withPersonalisationState(block: (PersonalizationState) -> Unit) {
+ withState { block(it.personalizationState) }
+ }
+
private fun updateProfilePicture() {
withState { state ->
when (val pictureUri = state.personalizationState.selectedPictureUri) {
@@ -955,8 +909,16 @@ class OnboardingViewModel @AssistedInject constructor(
}
private fun onProfilePictureSaved() {
+ completePersonalization()
+ }
+
+ private fun completePersonalization() {
_viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
}
+
+ private fun cancelWaitForEmailValidation() {
+ currentJob = null
+ }
}
private fun LoginMode.supportsSignModeScreen(): Boolean {
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
index bd5d93ae4d..8747de6da8 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
@@ -22,7 +22,6 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.PersistState
-import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ServerType
@@ -83,10 +82,6 @@ data class OnboardingViewState(
asyncDisplayName is Loading ||
asyncProfilePicture is Loading
}
-
- fun isAuthTaskCompleted(): Boolean {
- return asyncLoginAction is Success
- }
}
enum class OnboardingFlow {
@@ -97,6 +92,11 @@ enum class OnboardingFlow {
@Parcelize
data class PersonalizationState(
+ val supportsChangingDisplayName: Boolean = false,
+ val supportsChangingProfilePicture: Boolean = false,
val displayName: String? = null,
val selectedPictureUri: Uri? = null
-) : Parcelable
+) : Parcelable {
+
+ fun supportsPersonalization() = supportsChangingDisplayName || supportsChangingProfilePicture
+}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/RegistrationActionHandler.kt b/vector/src/main/java/im/vector/app/features/onboarding/RegistrationActionHandler.kt
new file mode 100644
index 0000000000..b4998d2ba0
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/onboarding/RegistrationActionHandler.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.onboarding
+
+import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
+import org.matrix.android.sdk.api.auth.registration.RegistrationResult
+import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
+import javax.inject.Inject
+
+class RegistrationActionHandler @Inject constructor() {
+
+ suspend fun handleRegisterAction(registrationWizard: RegistrationWizard, action: RegisterAction): RegistrationResult {
+ return when (action) {
+ RegisterAction.StartRegistration -> registrationWizard.getRegistrationFlow()
+ is RegisterAction.CaptchaDone -> registrationWizard.performReCaptcha(action.captchaResponse)
+ is RegisterAction.AcceptTerms -> registrationWizard.acceptTerms()
+ is RegisterAction.RegisterDummy -> registrationWizard.dummy()
+ is RegisterAction.AddThreePid -> registrationWizard.addThreePid(action.threePid)
+ is RegisterAction.SendAgainThreePid -> registrationWizard.sendAgainThreePid()
+ is RegisterAction.ValidateThreePid -> registrationWizard.handleValidateThreePid(action.code)
+ is RegisterAction.CheckIfEmailHasBeenValidated -> registrationWizard.checkIfEmailHasBeenValidated(action.delayMillis)
+ is RegisterAction.CreateAccount -> registrationWizard.createAccount(action.username, action.password, action.initialDeviceName)
+ }
+ }
+}
+
+sealed interface RegisterAction {
+ object StartRegistration : RegisterAction
+ data class CreateAccount(val username: String, val password: String, val initialDeviceName: String) : RegisterAction
+
+ data class AddThreePid(val threePid: RegisterThreePid) : RegisterAction
+ object SendAgainThreePid : RegisterAction
+
+ // TODO Confirm Email (from link in the email, open in the phone, intercepted by the app)
+ data class ValidateThreePid(val code: String) : RegisterAction
+
+ data class CheckIfEmailHasBeenValidated(val delayMillis: Long) : RegisterAction
+
+ data class CaptchaDone(val captchaResponse: String) : RegisterAction
+ object AcceptTerms : RegisterAction
+ object RegisterDummy : RegisterAction
+}
+
+fun RegisterAction.ignoresResult() = when (this) {
+ is RegisterAction.AddThreePid -> true
+ is RegisterAction.SendAgainThreePid -> true
+ else -> false
+}
+
+fun RegisterAction.hasLoadingState() = when (this) {
+ is RegisterAction.CheckIfEmailHasBeenValidated -> false
+ else -> true
+}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthAccountCreatedFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthAccountCreatedFragment.kt
index d021fd2813..49db52da67 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthAccountCreatedFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthAccountCreatedFragment.kt
@@ -20,17 +20,22 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isVisible
import im.vector.app.R
+import im.vector.app.core.animations.play
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.databinding.FragmentFtueAccountCreatedBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
+import im.vector.app.features.onboarding.OnboardingViewState
import javax.inject.Inject
class FtueAuthAccountCreatedFragment @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder
) : AbstractFtueAuthFragment() {
+ private var hasPlayedConfetti = false
+
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueAccountCreatedBinding {
return FragmentFtueAccountCreatedBinding.inflate(inflater, container, false)
}
@@ -42,8 +47,21 @@ class FtueAuthAccountCreatedFragment @Inject constructor(
private fun setupViews() {
views.accountCreatedSubtitle.text = getString(R.string.ftue_account_created_subtitle, activeSessionHolder.getActiveSession().myUserId)
- views.accountCreatedPersonalize.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnPersonalizeProfile)) }
+ views.accountCreatedPersonalize.debouncedClicks { viewModel.handle(OnboardingAction.PersonalizeProfile) }
views.accountCreatedTakeMeHome.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
+ views.accountCreatedTakeMeHomeCta.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
+ }
+
+ override fun updateWithState(state: OnboardingViewState) {
+ val canPersonalize = state.personalizationState.supportsPersonalization()
+ views.personalizeButtonGroup.isVisible = canPersonalize
+ views.takeMeHomeButtonGroup.isVisible = !canPersonalize
+
+ if (!hasPlayedConfetti && !canPersonalize) {
+ hasPlayedConfetti = true
+ views.viewKonfetti.isVisible = true
+ views.viewKonfetti.play()
+ }
}
override fun resetViewModel() {
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCaptchaFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCaptchaFragment.kt
index e2e390ae2d..4773332138 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCaptchaFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCaptchaFragment.kt
@@ -39,6 +39,7 @@ import im.vector.app.databinding.FragmentLoginCaptchaBinding
import im.vector.app.features.login.JavascriptResponse
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewState
+import im.vector.app.features.onboarding.RegisterAction
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.internal.di.MoshiProvider
import timber.log.Timber
@@ -181,7 +182,7 @@ class FtueAuthCaptchaFragment @Inject constructor(
val response = javascriptResponse?.response
if (javascriptResponse?.action == "verifyCallback" && response != null) {
- viewModel.handle(OnboardingAction.CaptchaDone(response))
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.CaptchaDone(response)))
}
}
return true
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthChooseProfilePictureFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthChooseProfilePictureFragment.kt
index bc1bf0c8bc..81300932db 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthChooseProfilePictureFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthChooseProfilePictureFragment.kt
@@ -22,6 +22,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
+import androidx.core.view.isInvisible
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
@@ -70,6 +71,8 @@ class FtueAuthChooseProfilePictureFragment @Inject constructor(
}
override fun updateWithState(state: OnboardingViewState) {
+ views.profilePictureToolbar.isInvisible = !state.personalizationState.supportsChangingDisplayName
+
val hasSetPicture = state.personalizationState.selectedPictureUri != null
views.profilePictureSubmit.isEnabled = hasSetPicture
views.changeProfilePictureIcon.setImageResource(if (hasSetPicture) R.drawable.ic_edit else R.drawable.ic_camera_plain)
@@ -93,4 +96,14 @@ class FtueAuthChooseProfilePictureFragment @Inject constructor(
override fun resetViewModel() {
// Nothing to do
}
+
+ override fun onBackPressed(toolbarButton: Boolean): Boolean {
+ return when (withState(viewModel) { it.personalizationState.supportsChangingDisplayName }) {
+ true -> super.onBackPressed(toolbarButton)
+ false -> {
+ viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome))
+ true
+ }
+ }
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt
index bd5054f646..2800530152 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt
@@ -37,6 +37,7 @@ import im.vector.app.databinding.FragmentLoginGenericTextInputFormBinding
import im.vector.app.features.login.TextInputFormFragmentMode
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
+import im.vector.app.features.onboarding.RegisterAction
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@@ -138,7 +139,7 @@ class FtueAuthGenericTextInputFormFragment @Inject constructor() : AbstractFtueA
private fun onOtherButtonClicked() {
when (params.mode) {
TextInputFormFragmentMode.ConfirmMsisdn -> {
- viewModel.handle(OnboardingAction.SendAgainThreePid)
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.SendAgainThreePid))
}
else -> {
// Should not happen, button is not displayed
@@ -152,19 +153,19 @@ class FtueAuthGenericTextInputFormFragment @Inject constructor() : AbstractFtueA
if (text.isEmpty()) {
// Perform dummy action
- viewModel.handle(OnboardingAction.RegisterDummy)
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.RegisterDummy))
} else {
when (params.mode) {
TextInputFormFragmentMode.SetEmail -> {
- viewModel.handle(OnboardingAction.AddThreePid(RegisterThreePid.Email(text)))
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.AddThreePid(RegisterThreePid.Email(text))))
}
TextInputFormFragmentMode.SetMsisdn -> {
getCountryCodeOrShowError(text)?.let { countryCode ->
- viewModel.handle(OnboardingAction.AddThreePid(RegisterThreePid.Msisdn(text, countryCode)))
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.AddThreePid(RegisterThreePid.Msisdn(text, countryCode))))
}
}
TextInputFormFragmentMode.ConfirmMsisdn -> {
- viewModel.handle(OnboardingAction.ValidateThreePid(text))
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.ValidateThreePid(text)))
}
}
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPersonalizationCompleteFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPersonalizationCompleteFragment.kt
new file mode 100644
index 0000000000..6b47b9830c
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPersonalizationCompleteFragment.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2021 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 im.vector.app.features.onboarding.ftueauth
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import im.vector.app.core.animations.play
+import im.vector.app.databinding.FragmentFtuePersonalizationCompleteBinding
+import im.vector.app.features.onboarding.OnboardingAction
+import im.vector.app.features.onboarding.OnboardingViewEvents
+import javax.inject.Inject
+
+class FtueAuthPersonalizationCompleteFragment @Inject constructor() : AbstractFtueAuthFragment() {
+
+ private var hasPlayedConfetti = false
+
+ override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtuePersonalizationCompleteBinding {
+ return FragmentFtuePersonalizationCompleteBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setupViews()
+ }
+
+ private fun setupViews() {
+ views.personalizationCompleteCta.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
+
+ if (!hasPlayedConfetti) {
+ hasPlayedConfetti = true
+ views.viewKonfetti.isVisible = true
+ views.viewKonfetti.play()
+ }
+ }
+
+ override fun resetViewModel() {
+ // Nothing to do
+ }
+
+ override fun onBackPressed(toolbarButton: Boolean): Boolean {
+ viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome))
+ return true
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt
index e336419e3f..79a974038b 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt
@@ -122,17 +122,9 @@ class FtueAuthVariant(
private fun updateWithState(viewState: OnboardingViewState) {
isForceLoginFallbackEnabled = viewState.isForceLoginFallbackEnabled
- views.loginLoading.isVisible = shouldShowLoading(viewState)
+ views.loginLoading.isVisible = viewState.isLoading()
}
- private fun shouldShowLoading(viewState: OnboardingViewState) =
- if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
- viewState.isLoading()
- } else {
- // Keep loading when during success because of the delay when switching to the next Activity
- viewState.isLoading() || viewState.isAuthTaskCompleted()
- }
-
override fun setIsLoading(isLoading: Boolean) = Unit
private fun handleOnboardingViewEvents(viewEvents: OnboardingViewEvents) {
@@ -230,13 +222,12 @@ class FtueAuthVariant(
FtueAuthUseCaseFragment::class.java,
option = commonOption)
}
- OnboardingViewEvents.OnAccountCreated -> onAccountCreated()
+ is OnboardingViewEvents.OnAccountCreated -> onAccountCreated()
OnboardingViewEvents.OnAccountSignedIn -> onAccountSignedIn()
- OnboardingViewEvents.OnPersonalizeProfile -> onPersonalizeProfile()
+ OnboardingViewEvents.OnChooseDisplayName -> onChooseDisplayName()
OnboardingViewEvents.OnTakeMeHome -> navigateToHome(createdAccount = true)
- OnboardingViewEvents.OnDisplayNameUpdated -> onDisplayNameUpdated()
- OnboardingViewEvents.OnDisplayNameSkipped -> onDisplayNameUpdated()
- OnboardingViewEvents.OnPersonalizationComplete -> navigateToHome(createdAccount = true)
+ OnboardingViewEvents.OnChooseProfilePicture -> onChooseProfilePicture()
+ OnboardingViewEvents.OnPersonalizationComplete -> onPersonalizationComplete()
OnboardingViewEvents.OnBack -> activity.popBackstack()
}.exhaustive
}
@@ -399,15 +390,12 @@ class FtueAuthVariant(
}
private fun onAccountCreated() {
- if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
- activity.supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- activity.replaceFragment(
- views.loginFragmentContainer,
- FtueAuthAccountCreatedFragment::class.java,
- )
- } else {
- navigateToHome(createdAccount = true)
- }
+ activity.supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ activity.replaceFragment(
+ views.loginFragmentContainer,
+ FtueAuthAccountCreatedFragment::class.java,
+ useCustomAnimation = true
+ )
}
private fun navigateToHome(createdAccount: Boolean) {
@@ -416,17 +404,26 @@ class FtueAuthVariant(
activity.finish()
}
- private fun onPersonalizeProfile() {
+ private fun onChooseDisplayName() {
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthChooseDisplayNameFragment::class.java,
option = commonOption
)
}
- private fun onDisplayNameUpdated() {
+ private fun onChooseProfilePicture() {
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthChooseProfilePictureFragment::class.java,
option = commonOption
)
}
+
+ private fun onPersonalizationComplete() {
+ activity.supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ activity.replaceFragment(
+ views.loginFragmentContainer,
+ FtueAuthPersonalizationCompleteFragment::class.java,
+ useCustomAnimation = true
+ )
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt
index 94758c7fad..ec72f52b9e 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt
@@ -25,6 +25,7 @@ import com.airbnb.mvrx.args
import im.vector.app.R
import im.vector.app.databinding.FragmentLoginWaitForEmailBinding
import im.vector.app.features.onboarding.OnboardingAction
+import im.vector.app.features.onboarding.RegisterAction
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.failure.is401
import javax.inject.Inject
@@ -54,7 +55,7 @@ class FtueAuthWaitForEmailFragment @Inject constructor() : AbstractFtueAuthFragm
override fun onResume() {
super.onResume()
- viewModel.handle(OnboardingAction.CheckIfEmailHasBeenValidated(0))
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.CheckIfEmailHasBeenValidated(0)))
}
override fun onPause() {
@@ -70,7 +71,7 @@ class FtueAuthWaitForEmailFragment @Inject constructor() : AbstractFtueAuthFragm
override fun onError(throwable: Throwable) {
if (throwable.is401()) {
// Try again, with a delay
- viewModel.handle(OnboardingAction.CheckIfEmailHasBeenValidated(10_000))
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.CheckIfEmailHasBeenValidated(10_000)))
} else {
super.onError(throwable)
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/terms/FtueAuthTermsFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/terms/FtueAuthTermsFragment.kt
index 5ce9a5350d..03598d3a47 100755
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/terms/FtueAuthTermsFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/terms/FtueAuthTermsFragment.kt
@@ -32,6 +32,7 @@ import im.vector.app.features.login.terms.LoginTermsViewState
import im.vector.app.features.login.terms.PolicyController
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewState
+import im.vector.app.features.onboarding.RegisterAction
import im.vector.app.features.onboarding.ftueauth.AbstractFtueAuthFragment
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.internal.auth.registration.LocalizedFlowDataLoginTerms
@@ -111,7 +112,7 @@ class FtueAuthTermsFragment @Inject constructor(
}
private fun submit() {
- viewModel.handle(OnboardingAction.AcceptTerms)
+ viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.AcceptTerms))
}
override fun updateWithState(state: OnboardingViewState) {
diff --git a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt
index 4510d64009..ff40462c13 100644
--- a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt
+++ b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt
@@ -60,9 +60,9 @@ class CreatePollController @Inject constructor(
pollTypeChangedListener { _, id ->
host.callback?.onPollTypeChanged(
if (id == R.id.openPollTypeRadioButton) {
- PollType.DISCLOSED
+ PollType.DISCLOSED_UNSTABLE
} else {
- PollType.UNDISCLOSED
+ PollType.UNDISCLOSED_UNSTABLE
}
)
}
diff --git a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt
index 5c7ef72297..2358f7f9a0 100644
--- a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt
@@ -71,9 +71,10 @@ class CreatePollViewModel @AssistedInject constructor(
val event = room.getTimelineEvent(eventId) ?: return
val content = event.getLastMessageContent() as? MessagePollContent ?: return
- val pollType = content.pollCreationInfo?.kind ?: PollType.DISCLOSED
- val question = content.pollCreationInfo?.question?.question ?: ""
- val options = content.pollCreationInfo?.answers?.mapNotNull { it.answer } ?: List(MIN_OPTIONS_COUNT) { "" }
+ val pollCreationInfo = content.getBestPollCreationInfo()
+ val pollType = pollCreationInfo?.kind ?: PollType.DISCLOSED_UNSTABLE
+ val question = pollCreationInfo?.question?.getBestQuestion() ?: ""
+ val options = pollCreationInfo?.answers?.mapNotNull { it.getBestAnswer() } ?: List(MIN_OPTIONS_COUNT) { "" }
setState {
copy(
diff --git a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewState.kt b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewState.kt
index 175d1b0116..fc3b746f32 100644
--- a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewState.kt
@@ -27,7 +27,7 @@ data class CreatePollViewState(
val options: List = List(CreatePollViewModel.MIN_OPTIONS_COUNT) { "" },
val canCreatePoll: Boolean = false,
val canAddMoreOptions: Boolean = true,
- val pollType: PollType = PollType.DISCLOSED
+ val pollType: PollType = PollType.DISCLOSED_UNSTABLE
) : MavericksState {
constructor(args: CreatePollArgs) : this(
diff --git a/vector/src/main/java/im/vector/app/features/poll/create/PollTypeSelectionItem.kt b/vector/src/main/java/im/vector/app/features/poll/create/PollTypeSelectionItem.kt
index 1b24a70cb9..0736c236b5 100644
--- a/vector/src/main/java/im/vector/app/features/poll/create/PollTypeSelectionItem.kt
+++ b/vector/src/main/java/im/vector/app/features/poll/create/PollTypeSelectionItem.kt
@@ -28,7 +28,7 @@ import org.matrix.android.sdk.api.session.room.model.message.PollType
abstract class PollTypeSelectionItem : VectorEpoxyModel() {
@EpoxyAttribute
- var pollType: PollType = PollType.DISCLOSED
+ var pollType: PollType = PollType.DISCLOSED_UNSTABLE
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var pollTypeChangedListener: RadioGroup.OnCheckedChangeListener? = null
@@ -38,8 +38,8 @@ abstract class PollTypeSelectionItem : VectorEpoxyModel R.id.openPollTypeRadioButton
- PollType.UNDISCLOSED -> R.id.closedPollTypeRadioButton
+ PollType.DISCLOSED_UNSTABLE, PollType.DISCLOSED -> R.id.openPollTypeRadioButton
+ PollType.UNDISCLOSED_UNSTABLE, PollType.UNDISCLOSED -> R.id.closedPollTypeRadioButton
}
)
diff --git a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt
index 0ae2a16b71..91b0f4d2f7 100644
--- a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt
+++ b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt
@@ -38,8 +38,13 @@ data class ElementWellKnown(
val riotE2E: E2EWellKnownConfig? = null,
@Json(name = "org.matrix.msc3488.tile_server")
+ val unstableMapTileServerConfig: MapTileServerConfig? = null,
+
+ @Json(name = "m.tile_server")
val mapTileServerConfig: MapTileServerConfig? = null
-)
+) {
+ fun getBestMapTileServerConfig() = mapTileServerConfig ?: unstableMapTileServerConfig
+}
@JsonClass(generateAdapter = true)
data class E2EWellKnownConfig(
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt
index 251b99e318..b13ef2a5d1 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt
@@ -54,6 +54,7 @@ import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedA
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
+import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
@@ -67,7 +68,8 @@ data class RoomProfileArgs(
class RoomProfileFragment @Inject constructor(
private val roomProfileController: RoomProfileController,
private val avatarRenderer: AvatarRenderer,
- private val roomDetailPendingActionStore: RoomDetailPendingActionStore
+ private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
+ private val matrixConfiguration: MatrixConfiguration
) :
VectorBaseFragment(),
RoomProfileController.Callback {
@@ -222,7 +224,7 @@ class RoomProfileFragment @Inject constructor(
avatarRenderer.render(matrixItem, views.matrixProfileToolbarAvatarImageView)
headerViews.roomProfileDecorationImageView.render(it.roomEncryptionTrustLevel)
views.matrixProfileDecorationToolbarAvatarImageView.render(it.roomEncryptionTrustLevel)
- headerViews.roomProfilePresenceImageView.render(it.isDirect, it.directUserPresence)
+ headerViews.roomProfilePresenceImageView.render(it.isDirect && matrixConfiguration.presenceSyncEnabled, it.directUserPresence)
headerViews.roomProfilePublicImageView.isVisible = it.isPublic && !it.isDirect
}
}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt
index 7788584a07..964b081bb7 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/members/RoomMemberListController.kt
@@ -27,6 +27,7 @@ import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.AvatarRenderer
import me.gujun.android.span.span
+import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
@@ -39,7 +40,8 @@ class RoomMemberListController @Inject constructor(
private val avatarRenderer: AvatarRenderer,
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider,
- private val roomMemberSummaryFilter: RoomMemberSummaryFilter
+ private val roomMemberSummaryFilter: RoomMemberSummaryFilter,
+ private val matrixConfiguration: MatrixConfiguration
) : TypedEpoxyController() {
interface Callback {
@@ -128,6 +130,7 @@ class RoomMemberListController @Inject constructor(
host: RoomMemberListController,
data: RoomMemberListViewState) {
val powerLabel = stringProvider.getString(powerLevelCategory.titleRes)
+ val presenceSyncEnabled = matrixConfiguration.presenceSyncEnabled
profileMatrixItemWithPowerLevelWithPresence {
id(roomMember.userId)
@@ -137,6 +140,7 @@ class RoomMemberListController @Inject constructor(
clickListener {
host.callback?.onRoomMemberClicked(roomMember)
}
+ showPresence(presenceSyncEnabled)
userPresence(roomMember.userPresence)
powerLevelLabel(
span {
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/notifications/RoomNotificationSettingsViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/notifications/RoomNotificationSettingsViewState.kt
index 6197f23105..4c3f1a1655 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/notifications/RoomNotificationSettingsViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/notifications/RoomNotificationSettingsViewState.kt
@@ -40,14 +40,16 @@ data class RoomNotificationSettingsViewState(
*/
val RoomNotificationSettingsViewState.notificationStateMapped: Async
get() {
- if ((roomSummary()?.isEncrypted == true && notificationState() == RoomNotificationState.MENTIONS_ONLY) /*||
- notificationState() == RoomNotificationState.ALL_MESSAGES*/) {
- /** if in an encrypted room, mentions notifications are not supported so show "All Messages" as selected.
+ return when {
+ /**
+ * if in an encrypted room, mentions notifications are not supported so show "None" as selected.
* Also in the new settings there is no notion of notifications without sound so it maps to noisy also
*/
- return Success(RoomNotificationState.ALL_MESSAGES_NOISY)
+ (roomSummary()?.isEncrypted == true && notificationState() == RoomNotificationState.MENTIONS_ONLY)
+ -> Success(RoomNotificationState.MUTE)
+ //notificationState() == RoomNotificationState.ALL_MESSAGES -> Success(RoomNotificationState.ALL_MESSAGES_NOISY)
+ else -> notificationState
}
- return notificationState
}
/**
diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt
index 7916dbc2ac..b2dcd3016b 100755
--- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt
@@ -235,7 +235,13 @@ class VectorPreferences @Inject constructor(private val context: Context, privat
private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE"
private const val SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE = "SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE"
- const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES"
+
+ // This key will be used to identify clients with the old thread support enabled io.element.thread
+ const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES"
+
+ // This key will be used to identify clients with the new thread support enabled m.thread
+ const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES_FINAL"
+ const val SETTINGS_THREAD_MESSAGES_SYNCED = "SETTINGS_THREAD_MESSAGES_SYNCED"
// Possible values for TAKE_PHOTO_VIDEO_MODE
const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0
@@ -1243,7 +1249,56 @@ class VectorPreferences @Inject constructor(private val context: Context, privat
return defaultPrefs.getBoolean(SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE, true)
}
+ /**
+ * Indicates whether or not thread messages are enabled
+ */
fun areThreadMessagesEnabled(): Boolean {
- return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_THREAD_MESSAGES, false)
+ return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_THREAD_MESSAGES, getDefault(R.bool.settings_labs_thread_messages_default))
+ }
+
+ /**
+ * Manually sets thread messages enabled, useful for migrating users from io.element.thread
+ */
+ fun setThreadMessagesEnabled() {
+ defaultPrefs
+ .edit()
+ .putBoolean(SETTINGS_LABS_ENABLE_THREAD_MESSAGES, true)
+ .apply()
+ }
+
+ /**
+ * Indicates whether or not the user will be notified about the new thread support
+ * We should notify the user only if he had old thread support enabled
+ */
+ fun shouldNotifyUserAboutThreads(): Boolean {
+ return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS, false)
+ }
+
+ /**
+ * Indicates that the user have been notified about threads migration
+ */
+ fun userNotifiedAboutThreads() {
+ defaultPrefs
+ .edit()
+ .putBoolean(SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS, false)
+ .apply()
+ }
+
+ /**
+ * Indicates whether or not we should clear cache for threads migration.
+ * Default value is true, for fresh installs and updates
+ */
+ fun shouldMigrateThreads(): Boolean {
+ return defaultPrefs.getBoolean(SETTINGS_THREAD_MESSAGES_SYNCED, true)
+ }
+
+ /**
+ * Indicates that there no longer threads migration needed
+ */
+ fun setShouldMigrateThreads(shouldMigrate: Boolean) {
+ defaultPrefs
+ .edit()
+ .putBoolean(SETTINGS_THREAD_MESSAGES_SYNCED, shouldMigrate)
+ .apply()
}
}
diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt
index 875704d7db..e0d58fecee 100644
--- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt
@@ -70,6 +70,8 @@ class VectorSettingsLabsFragment @Inject constructor(
// clear cache
findPreference(VectorPreferences.SETTINGS_LABS_ENABLE_THREAD_MESSAGES)?.let {
it.onPreferenceClickListener = Preference.OnPreferenceClickListener {
+ // We should migrate threads only if threads are disabled
+ vectorPreferences.setShouldMigrateThreads(!vectorPreferences.areThreadMessagesEnabled())
lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled())
displayLoadingView()
MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCache = true))
diff --git a/vector/src/main/java/im/vector/app/features/spaces/manage/AddRoomListController.kt b/vector/src/main/java/im/vector/app/features/spaces/manage/AddRoomListController.kt
index 3164daf634..c2d63aa8d3 100644
--- a/vector/src/main/java/im/vector/app/features/spaces/manage/AddRoomListController.kt
+++ b/vector/src/main/java/im/vector/app/features/spaces/manage/AddRoomListController.kt
@@ -94,6 +94,12 @@ class AddRoomListController @Inject constructor(
}
var totalSize: Int = 0
+ set(value) {
+ if (value != field) {
+ field = value
+ requestForcedModelBuild()
+ }
+ }
var selectedItems: Map = emptyMap()
set(value) {
@@ -120,7 +126,8 @@ class AddRoomListController @Inject constructor(
add(
RoomCategoryItem_().apply {
id("header")
- title(host.sectionName ?: "")
+ title(host.sectionName.orEmpty())
+ itemCount(host.totalSize)
expanded(host.expanded)
listener {
host.expanded = !host.expanded
diff --git a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt
index bcf0a8a949..8d6a351013 100644
--- a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt
@@ -22,6 +22,8 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
@@ -35,9 +37,12 @@ import im.vector.app.core.extensions.cleanup
import im.vector.app.core.platform.OnBackPressed
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentSpaceAddRoomsBinding
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import reactivecircus.flowbinding.appcompat.queryTextChanges
import javax.inject.Inject
@@ -169,48 +174,63 @@ class SpaceAddRoomFragment @Inject constructor(
}
private fun setupRecyclerView() {
- val concatAdapter = ConcatAdapter()
- spaceEpoxyController.sectionName = getString(R.string.spaces_header)
- roomEpoxyController.sectionName = getString(R.string.rooms_header)
- spaceEpoxyController.listener = this
- roomEpoxyController.listener = this
+ setupSpaceSection()
+ setupRoomSection()
+ setupDmSection()
- viewModel.updatableLiveSpacePageResult.liveBoundaries.observe(viewLifecycleOwner) {
+ views.roomList.adapter = ConcatAdapter().apply {
+ addAdapter(roomEpoxyController.adapter)
+ addAdapter(spaceEpoxyController.adapter)
+ addAdapter(dmEpoxyController.adapter)
+ }
+ }
+
+ private fun setupSpaceSection() {
+ spaceEpoxyController.sectionName = getString(R.string.spaces_header)
+ spaceEpoxyController.listener = this
+ viewModel.spaceUpdatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
spaceEpoxyController.boundaryChange(it)
}
- viewModel.updatableLiveSpacePageResult.livePagedList.observe(viewLifecycleOwner) {
- spaceEpoxyController.totalSize = it.size
+ viewModel.spaceUpdatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
spaceEpoxyController.submitList(it)
}
+ listenItemCount(viewModel.spaceCountFlow) { spaceEpoxyController.totalSize = it }
+ }
- viewModel.updatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
+ private fun setupRoomSection() {
+ roomEpoxyController.sectionName = getString(R.string.rooms_header)
+ roomEpoxyController.listener = this
+
+ viewModel.roomUpdatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
roomEpoxyController.boundaryChange(it)
}
- viewModel.updatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
- roomEpoxyController.totalSize = it.size
+ viewModel.roomUpdatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
roomEpoxyController.submitList(it)
}
-
+ listenItemCount(viewModel.roomCountFlow) { roomEpoxyController.totalSize = it }
views.roomList.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
views.roomList.setHasFixedSize(true)
+ }
- concatAdapter.addAdapter(roomEpoxyController.adapter)
- concatAdapter.addAdapter(spaceEpoxyController.adapter)
-
+ private fun setupDmSection() {
// This controller can be disabled depending on the space type (public or not)
- viewModel.updatableDMLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
- dmEpoxyController.boundaryChange(it)
- }
- viewModel.updatableDMLivePageResult.livePagedList.observe(viewLifecycleOwner) {
- dmEpoxyController.totalSize = it.size
- dmEpoxyController.submitList(it)
- }
dmEpoxyController.sectionName = getString(R.string.direct_chats_header)
dmEpoxyController.listener = this
+ viewModel.dmUpdatableLivePageResult.liveBoundaries.observe(viewLifecycleOwner) {
+ dmEpoxyController.boundaryChange(it)
+ }
+ viewModel.dmUpdatableLivePageResult.livePagedList.observe(viewLifecycleOwner) {
+ dmEpoxyController.submitList(it)
+ }
+ listenItemCount(viewModel.dmCountFlow) { dmEpoxyController.totalSize = it }
+ }
- concatAdapter.addAdapter(dmEpoxyController.adapter)
-
- views.roomList.adapter = concatAdapter
+ private fun listenItemCount(itemCountFlow: Flow, onEachAction: (Int) -> Unit) {
+ lifecycleScope.launch {
+ itemCountFlow
+ .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
+ .collect { count -> onEachAction(count) }
+ }
}
override fun onBackPressed(toolbarButton: Boolean): Boolean {
diff --git a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomsViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomsViewModel.kt
index 8fa269d439..318f701985 100644
--- a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomsViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomsViewModel.kt
@@ -17,7 +17,7 @@
package im.vector.app.features.spaces.manage
import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.asFlow
import androidx.paging.PagedList
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
@@ -30,6 +30,9 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.query.ActiveSpaceFilter
@@ -60,7 +63,7 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory()
- val updatableLiveSpacePageResult: UpdatableLivePageResult by lazy {
+ val spaceUpdatableLivePageResult: UpdatableLivePageResult by lazy {
session.getFilteredPagedRoomSummariesLive(
roomSummaryQueryParams {
this.memberships = listOf(Membership.JOIN)
@@ -79,7 +82,13 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
)
}
- val updatableLivePageResult: UpdatableLivePageResult by lazy {
+ val spaceCountFlow: Flow by lazy {
+ spaceUpdatableLivePageResult.livePagedList.asFlow()
+ .flatMapLatest { session.getRoomCountLive(spaceUpdatableLivePageResult.queryParams).asFlow() }
+ .distinctUntilChanged()
+ }
+
+ val roomUpdatableLivePageResult: UpdatableLivePageResult by lazy {
session.getFilteredPagedRoomSummariesLive(
roomSummaryQueryParams {
this.memberships = listOf(Membership.JOIN)
@@ -99,7 +108,13 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
)
}
- val updatableDMLivePageResult: UpdatableLivePageResult by lazy {
+ val roomCountFlow: Flow by lazy {
+ roomUpdatableLivePageResult.livePagedList.asFlow()
+ .flatMapLatest { session.getRoomCountLive(roomUpdatableLivePageResult.queryParams).asFlow() }
+ .distinctUntilChanged()
+ }
+
+ val dmUpdatableLivePageResult: UpdatableLivePageResult by lazy {
session.getFilteredPagedRoomSummariesLive(
roomSummaryQueryParams {
this.memberships = listOf(Membership.JOIN)
@@ -119,6 +134,12 @@ class SpaceAddRoomsViewModel @AssistedInject constructor(
)
}
+ val dmCountFlow: Flow by lazy {
+ dmUpdatableLivePageResult.livePagedList.asFlow()
+ .flatMapLatest { session.getRoomCountLive(dmUpdatableLivePageResult.queryParams).asFlow() }
+ .distinctUntilChanged()
+ }
+
private val selectionList = mutableMapOf()
val selectionListLiveData = MutableLiveData
Unohda kaikki viestit, jotka olen lähettänyt, kun tilini on poistettu (Varoitus: tästä seuraa, että tulevat käyttäjät näkevät vanhat keskustelut epätäydellisinä)
Syötä käyttäjätunnus.
- Tämä huone on korvattu toisella huoneella
+ Tämä huone on korvattu toisella eikä ole enää aktiivinen.
Keskustelu jatkuu täällä
Tämä huone on jatkoa toiselle keskustelulle
Paina tästä nähdäksesi vanhemmat viestit
@@ -801,9 +802,9 @@
%s kutsui
Sinulla ei ole enempää lukemattomia viestejä
Keskustelut
- Yksityisviestisi näytetään tässä. Napsauta + oikeasta alakulmasta aloittaaksesi.
+ Yksityisviestisi näytetään tässä. Napauta + oikeasta alakulmasta aloittaaksesi keskustelun.
Huoneet
- Huoneesi näytetään tässä. Napsauta + oikeasta alakulmasta aloittaaksesi.
+ Huoneesi näytetään tässä. Napauta + oikeasta alakulmasta löytääksesi olemassa olevia tai perustaaksesi omiasi.
Reaktiot
Samaa mieltä
Lisää reaktio
@@ -1405,7 +1406,7 @@
Viestieditori
Muut kielet
Näytä merkki poistettujen viestien paikalla
- Käytä /snow kometoa tai lähetä viesti jossa on ❄️ tai 🎉
+ Käytä /confetti-komentoa tai lähetä viesti jossa on ❄️ tai 🎉
Näytä keskustelujen tehosteet
Näytä poistetut viestit
Jos poistat käyttäjän porttikiellon, hän voi liittyä huoneeseen uudelleen.
@@ -1507,7 +1508,7 @@
Ensimmäinen synkronointi:
\nLadataan tietoja…
Ensimmäinen synkronointi:
-\nOdotetaan palvelimen vastausta. . .
+\nOdotetaan palvelimen vastausta…
- %1$s, %2$s, %3$s ja %4$d muu
- %1$s, %2$s, %3$s ja %4$d muuta
@@ -1983,4 +1984,73 @@
Yhdistä palvelimeen
Minulla on jo tili
Luo tili
+ ${app_name} ei voinut käyttää sijaintiasi. Yritä myöhemmin uudelleen.
+ ${app_name} ei voinut käyttää sijaintiasi
+ Haluatko varmasti poistaa tämän kyselyn\? Et voi palauttaa sitä poistamisen jälkeen.
+ Huomaa: sovellus käynnistetään uudelleen
+ Tapahtuman sisältö
+ Tapahtuman sisältö
+ Lähetä mukautettu tapahtuma
+ Oletusluottamustaso
+ Huonetta, johon olet saanut porttikiellon ei voi avata.
+ Noudetaan yhteystietojasi…
+ Lähettää viestin lumisateen kera
+ Lähettää viestin konfetin kera
+ ${app_name} iOS
+\n${app_name} Android
+ Lähettää viestin pelkkänä tekstinä, tulkitsematta sitä markdowniksi
+ Ota yhteyttä ylläpitäjään salauksen palauttamiseksi kelvolliseen tilaan.
+ Lue koodi toisella laitteellasi tai vaihda ja lue tällä laitteella
+ Ei-luotettu kirjautuminen
+ Teit tästä kutsua edellyttävän.
+ Sijainti
+ Kysely
+
+ - %1$s, %2$s ja yksi muu lukivat
+ - %1$s, %2$s ja %3$d muuta lukivat
+
+ Hyväksytkö näiden tietojen lähettämisen\?
+ Versiot
+ Ohje ja tuki
+ Ohje
+ Huone on luotu, mutta joitakin kutsuja ei ole lähetetty seuraavasta syystä:
+\n
+\n%s
+ Tähän huoneeseen ei pääse tällä hetkellä.
+\nYritä myöhemmin uudelleen tai kysy huoneen ylläpitäjältä onko sinulla pääsyä.
+ Näytä huoneessa
+ Tuntematon pääsyasetus (%s)
+ Aseta osoitteita tälle huoneelle, jotta käyttäjät voivat löytää tämän huoneen kotipalvelimesi (%1$s) kautta
+ Uusi julkaistu osoite (esim. #alias:palvelin)
+ Kuka hyvänsä millä hyvänsä palvelimella voi käyttää julkaistua osoitetta huoneeseesi liittymiseen. Osoitteen julkaisemiseksi se täytyy ensin asettaa paikalliseksi osoitteeksi.
+ Kolmansien osapuolten kirjastot
+ Voit poistaa tämän käytöstä koska tahansa asetuksista
+ Emme jaa tietoa kolmansien tahojen kanssa
+ Emme tallenna tai profiloi mitään tilin tietoja
+ Huoneesta on poistuttu!
+ Sinulla ei ole lupaa päivittää rooleja, jotka vaaditaan huoneen eri osien muuttamiseen
+ Valitse roolit, jotka vaaditaan huoneen eri osien muuttamiseen
+ Tarkastele ja päivitä rooleja, jotka vaaditaan huoneen eri osien muuttamiseen.
+ %1$s, %2$s ja muita
+ %1$s ja %2$s
+ Kotipalvelinta URL-osoitteesta %s ei tavoiteta. Tarkista linkki tai valitse kotipalvelin manuaalisesti.
+ Näytä huoneessa
+ Ota käyttöön
+
+ - Poistit tältä huoneelta vaihtoehtoisen osoitteen %1$s.
+ - Poistit tältä huoneelta vaihtoehtoiset osoitteet %1$s.
+
+
+ - %1$s poisti tältä huoneelta vaihtoehtoisen osoitteen %2$s.
+ - %1$s poisti tältä huoneelta vaihtoehtoiset osoitteet %2$s.
+
+
+ - Lisäsit tälle huoneelle vaihtoehtoisen osoitteen %1$s.
+ - Lisäsit tälle huoneelle vaihtoehtoiset osoitteet %1$s.
+
+
+ - %1$s lisäsi tälle huoneelle vaihtoehtoisen osoitteen %2$s.
+ - %1$s lisäsi tälle huoneelle vaihtoehtoiset osoitteet %2$s.
+
+ Sinulla ei ole lupaa liittyä tähän huoneeseen
\ No newline at end of file
diff --git a/vector/src/main/res/values-fr-rCA/strings.xml b/vector/src/main/res/values-fr-rCA/strings.xml
index cf2ef1a13f..c54c7c6222 100644
--- a/vector/src/main/res/values-fr-rCA/strings.xml
+++ b/vector/src/main/res/values-fr-rCA/strings.xml
@@ -150,7 +150,6 @@
\nVos messages sont sécurisés avec des verrous et seuls vous et le destinataire avez les clés uniques pour les déverrouiller.
Les messages ici ne sont pas chiffrés de bout en bout.
Les messages dans ce salon ne sont pas chiffrés de bout en bout.
-
Nous attendons %s…
%s a été vérifié
Vérifier %s
@@ -396,7 +395,6 @@
\nLes gestionnaires d’intégrations reçoivent des données de configuration et peuvent modifier des gadgets logiciels, envoyer des invitations de salon et définir des rangs à votre place.
Taille maximum pour des téléversements sur ce serveur
Téléversements
-
Mot de passe oublié\?
Vous n’avez aucun jeu d\'autocollants activé pour le moment.
\n
@@ -422,8 +420,6 @@
Impossible de valider le NIP, veillez en composer un nouveau.
Confirmez le NIP
Choisissez un NIP par sécurité
-
-
- %d entrée
- %d entrées
@@ -476,8 +472,6 @@
- Sauvegarde restaurée avec %d clé.
- Sauvegarde restaurée avec %d clés.
-
-
- %d gadget logiciel actif
- %d gadgets logiciels actifs
@@ -498,7 +492,6 @@
- %d salon
- %d salons
-
- %d message notifié non lu
- %d messages notifiés non lu
@@ -515,8 +508,6 @@
- %d seconde
- %d secondes
-
-
Contenu de l’évènement
Contenu d’évènement
Envoyer des évènements d’état personnalisés
@@ -532,21 +523,15 @@
- %d nouveau message
- %d nouveaux messages
-
-
-
-
- %d membre
- %d membres
Voulez-vous vraiment quitter le salon\?
-
- %d changement de statut
- %d changements de statut
-
- Vous avez supprimé l’adresse alternative %1$s de ce salon.
- Vous avez supprimé les adresses alternatives %1$s de ce salon.
@@ -582,11 +567,9 @@
\nDésactiver votre compte ne nous fait pas oublier les messages que vous avez envoyés par défaut. Si vous souhaitez que nous oubliions vos messages, cochez la case ci-dessous.
\n
\nLa visibilité des messages dans Matrix est identique à celle des courriels. Si nous oublions vos messages, cela signifie que les messages que vous avez envoyés ne seront plus partagés avec les nouveaux utilisateurs ou les utilisateurs non enregistrés, mais les utilisateurs enregistrés qui ont déjà accès à ces messages en conserveront leur copie.
-
Impossible de vérifier l’adresse courriel : assurez-vous d’avoir cliqué sur le lien dans l’courriel
Aucune adresse courriel n’a été ajoutée à votre compte
Cette adresse courriel est déjà utilisée.
-
L’adresse courriel liée à votre compte doit être saisie.
Vous n’avez pas accès à ce message
Définir l’avatar
@@ -739,7 +722,6 @@
Si vous annulez maintenant, vous pourrez perdre les messages et données chiffrés si vous perdez accès à vos identifiants.
\n
\nVous pouvez aussi activer la sauvegarde sécurisée et gérer vos clés dans les paramètres.
-
Copiez-le sur votre stockage dans le cloud personnel
Sauvegardez-le sur une clé USB ou un disque de sauvegarde
Imprimez-le et conservez-le en lieu sûr
@@ -765,8 +747,6 @@
Phrase de récupération
%s veut vérifier votre session
Demande de vérification
-
-
Compris
Vérifié !
Nouvelle invitation
@@ -780,7 +760,6 @@
Tous les salons sur le serveur %s
URL du serveur d’accueil
Sélectionner un répertoire de salons
-
Si elles ne correspondent pas, la sécurité de votre communication est peut-être compromise.
Confirmez en comparant les informations suivantes avec les paramètres utilisateur dans votre autre session :
Vérifier
@@ -796,7 +775,6 @@
Gérer la sauvegarde de clés
Récupération des messages chiffrés
Les clés ont bien été exportées
-
Veuillez créer une phrase secrète pour chiffrer les clés exportées. Vous devrez saisir cette même phrase secrète afin de pouvoir importer les clés.
Exporter
Exporter les clés vers un fichier local
@@ -807,7 +785,6 @@
Nom public
Erreur de déchiffrement
Thème
-
Désactiver comme adresse principale
Définir comme adresse principale
Ce sont des fonctionnalités expérimentales qui peuvent se comporter de façon inattendue. À utiliser avec précaution.
@@ -1014,7 +991,6 @@
Tous les messages
Tous les messages (sonore)
Bloquer l’utilisateur
-
Ce contenu a été signalé comme inapproprié.
\n
\nSi vous ne voulez plus voir de contenu de cet utilisateur, vous pouvez l’ignorer pour masquer ses messages.
@@ -1045,7 +1021,6 @@
Serveur d’accueil
Connecté en tant que
Authentification
-
%1$s @ %2$s
Vu la dernière fois
Mettre à jour le nom public
@@ -1139,7 +1114,6 @@
Filtrer les utilisateurs exclus
Filtrer les membres du salon
Rechercher
-
Changer le sujet
Mettre à niveau le salon
Changer les permissions
@@ -1188,7 +1162,6 @@
Nous vous avons envoyé un courriel de confirmation à %s, consultez vos courriels et cliquez sur le lien de confirmation
Envoyer des courriels et des numéros de téléphone
Vous avez donné votre autorisation pour envoyer des courriels et des numéros de téléphone à ce serveur d’identité pour découvrir d\'autres utilisateurs à partir de vos contacts.
-
Vous partagez actuellement des adresse courriels et des numéros de téléphone sur le serveur d’identité %1$s. Vous devrez vous reconnecter à %2$s pour arrêter de les partager.
Acceptez les conditions de service du serveur d’identité (%s) pour vous permettre d’être découvrable avec une adresse courriel ou un numéro de téléphone.
Un courriel de vérification sera envoyé à votre adresse pour confirmer la configuration de votre nouveau mot de passe.
@@ -1221,7 +1194,6 @@
- %1$s a ajouté %2$s comme adresse pour ce salon.
- %1$s a ajouté %2$s comme adresses pour ce salon.
-
- %1$s, %2$s, %3$s et %4$d autre
- %1$s, %2$s, %3$s et %4$d autres
@@ -1289,7 +1261,6 @@
Partager par SMS
Impossible de trouver ce salon. Assurez-vous qu’il existe.
Impossible d’ouvrir un salon dont vous êtes banni.
-
Signature
Algorithme
Version
@@ -1298,7 +1269,6 @@
On dirait que vous avez déjà configuré une sauvegarde de clé depuis une autre session. Voulez-vous la remplacer par celle que vous êtes en train de créer \?
Une sauvegarde est déjà disponible sur votre serveur d’accueil
La clé de récupération a été enregistrée.
-
Enregistrer dans un fichier
Partager
Sauvegarder la clé de récupération
@@ -1479,7 +1449,6 @@
Impossible d\'établir une connexion en temps réel.
\nVeuillez demander à l’administrateur de votre serveur d’accueil de configurer un serveur TURN afin que les appels fonctionnent de manière fiable.
Appel échoué
-
Envoyer un message vocal
Nouvel appel vidéo
Nouvel appel audio
@@ -1554,7 +1523,6 @@
Vous n\'avez pas la permission de lancer un appel dans ce salon
Vous n’avez pas la permission de lancer une téléconférence
Vous n’avez pas la permission de lancer une téléconférence dans ce salon
-
Commencer une conversation
Réinitialiser
Ignorer
@@ -1588,7 +1556,6 @@
Ne perdez jamais vos messages chiffrés
Sécurité contre la perte d’accès aux messages et données chiffrées
Sauvegarde sécurisée
-
Supprimer les clés de chiffrement sauvegardées sur le serveur \? Vous ne pourrez plus utiliser votre clé de récupération pour lire l’historique des messages chiffrés.
Supprimer la sauvegarde
Vérification de l’état de la sauvegarde
@@ -1631,7 +1598,6 @@
Veuillez en faire une copie
Arrêter
Remplacer
-
Gérer vos paramètres de découverte.
Découverte
Désactiver mon compte
@@ -1689,7 +1655,6 @@
Version de olm
Version
Délai entre chaque synchronisation
-
Délai d’attente de la requête de synchronisation
Lancer au démarrage
Vous ne serez pas notifié des messages entrants quand l’application est en arrière-plan.
@@ -1843,7 +1808,7 @@
• Les serveurs correspondant à des IP littérales sont maintenant interdits.
• Les serveurs correspondants à des IP littérales sont maintenant autorisés.
• Les serveurs correspondant à %s sont supprimés de la liste autorisée.
- • les serveur correspondant à %s sont maintenant autorisés.
+ • les serveurs correspondant à %s sont maintenant autorisés.
• Les serveurs correspondant à %s étaient supprimés de la liste des interdits.
• Les serveurs correspondant à %s sont maintenant interdits.
Vous avez changé les droits ACL du serveur pour ce salon.
@@ -1927,10 +1892,7 @@
${app_name} a besoin d’accéder à votre appareil photo et à votre microphone pour passer des appels vidéo.
\n
\nVeuillez autoriser l’accès dans les prochaines fenêtres pour pouvoir effectuer l’appel.
-
${app_name} a besoin d’accéder à votre microphone pour passer des appels audio.
-
-
Information
Le correspondant n’a pas décroché.
Vous avez mis l’appel en attente
@@ -1966,7 +1928,6 @@
Ce n’est pas une adresse de serveur Matrix valide
Veuillez saisir une URL valide
Veuillez lire et accepter les politiques de ce serveur d’accueil :
-
Ce serveur d’accueil souhaite s’assurer que vous n’êtes pas un robot
Le numéro de téléphone est déjà défini.
Nom d’utilisateur et/ou mot de passe incorrect
@@ -2100,4 +2061,17 @@
Remarques
Remarques sur les espaces
Désolé, une erreur s’est produite en essayant d’entrer dans la conférence
+ Certaines permissions manquent pour effectuer cette action, veuillez autoriser ces permissions depuis les réglages système.
+ Espaces
+ Écoute des notifications
+ Vous n’êtes pas autorisé(e) à rejoindre ce salon
+
+ - %d changement des ACL du serveur
+ - %d changements des ACL du serveur
+
+ Activer
+ Voir les fils de discussions
+ Permissions manquantes
+ Pour envoyer des messages vocaux, veuillez accorder la permission Microphone.
+ Pour effectuer cette action, veuillez autoriser la permission Caméra depuis les réglages système.
\ No newline at end of file
diff --git a/vector/src/main/res/values-fr/strings.xml b/vector/src/main/res/values-fr/strings.xml
index 6bae118e59..e1b3b0043c 100644
--- a/vector/src/main/res/values-fr/strings.xml
+++ b/vector/src/main/res/values-fr/strings.xml
@@ -34,7 +34,7 @@
L’appareil de l’expéditeur ne nous a pas envoyé les clés pour ce message.
Envoi du message impossible
Erreur de Matrix
- Adresse e-mail
+ Adresse électronique
Numéro de téléphone
Invitation au salon
Salon vide
@@ -290,7 +290,7 @@
Aucun résultat
Image de profil
Nom affiché
- Ajouter une adresse e-mail
+ Ajouter une adresse électronique
Ajouter un numéro de téléphone
Affiche les informations de l’application dans les paramètres système.
Informations sur l’application
@@ -372,11 +372,11 @@
L’application s’est arrêtée anormalement la dernière fois. Souhaitez-vous ouvrir l’écran de rapport d’anomalie \?
Le rapport d’anomalie a bien été envoyé
L’envoi du rapport d’anomalie a échoué (%s)
- Ceci ne ressemble pas à une adresse e-mail valide
- Cette adresse e-mail est déjà utilisée.
+ Ceci ne ressemble pas à une adresse électronique valide
+ Cette adresse électronique est déjà utilisée.
Ce serveur d’accueil souhaite s’assurer que vous n’êtes pas un robot
- L’adresse e-mail liée à votre compte doit être saisie.
- Impossible de vérifier l’adresse e-mail : assurez-vous d’avoir cliqué sur le lien dans l’e-mail
+ L’adresse électronique liée à votre compte doit être saisie.
+ Impossible de vérifier l’adresse électronique : assurez-vous d’avoir cliqué sur le lien dans le courriel
Trop de requêtes ont été envoyées
Quitter
Citer
@@ -406,7 +406,7 @@
Quand je suis invité sur un salon
Paramètres utilisateur
%1$s @ %2$s
- Vérifiez votre e-mail et cliquez sur le lien qu’il contient. Une fois cela fait, cliquez sur continuer.
+ Vérifiez votre courriel et cliquez sur le lien qu’il contient. Une fois ceci fait, cliquez sur continuer.
Afficher tous les messages de %s \?
\n
\nVeuillez noter que cette action redémarrera l’application et pourra prendre un certain temps.
@@ -422,7 +422,7 @@
Interface utilisateur
Langue
Choisissez une langue
- Cette adresse e-mail est déjà utilisée.
+ Cette adresse électronique est déjà utilisée.
Ce numéro de téléphone est déjà utilisé.
Lancer au démarrage
Vider le cache des médias
@@ -533,11 +533,11 @@
Pour continuer à utiliser le serveur d’accueil %1$s, vous devez lire et accepter les conditions générales.
Voir maintenant
Désactiver le compte
- Votre compte sera inutilisable de façon permanente. Vous ne pourrez plus vous connecter et personne ne pourra se réenregistrer avec le même identifiant d’utilisateur. Le compte quittera tous les salons auxquels il participe et tous ses détails seront supprimés du serveur d’identité. Cette action est irréversible.
-\n
-\nDésactiver votre compte ne nous fait pas oublier les messages que vous avez envoyés par défaut. Si vous souhaitez que nous oubliions vos messages, cochez la case ci-dessous.
-\n
-\nLa visibilité des messages dans Matrix est identique à celle des e-mails. Si nous oublions vos messages, cela signifie que les messages que vous avez envoyés ne seront plus partagés avec les nouveaux utilisateurs ou les utilisateurs non enregistrés, mais les utilisateurs enregistrés qui ont déjà accès à ces messages en conserveront leur copie.
+ Votre compte sera inutilisable de façon permanente. Vous ne pourrez plus vous connecter et personne ne pourra se réenregistrer avec le même identifiant d’utilisateur. Le compte quittera tous les salons auxquels il participe et tous ses détails seront supprimés du serveur d’identité. Cette action est irréversible.
+\n
+\nDésactiver votre compte ne nous fait pas oublier les messages que vous avez envoyés par défaut. Si vous souhaitez que nous oubliions vos messages, cochez la case ci-dessous.
+\n
+\nLa visibilité des messages dans Matrix est identique à celle des courriels. Si nous oublions vos messages, cela signifie que les messages que vous avez envoyés ne seront plus partagés avec les nouveaux utilisateurs ou les utilisateurs non enregistrés, mais les utilisateurs enregistrés qui ont déjà accès à ces messages en conserveront leur copie.
Veuillez oublier tous les messages que j’ai envoyé quand mon compte sera désactivé (Avertissement : les futurs utilisateurs verront une version incomplète des conversations)
Désactiver le compte
Télécharger
@@ -941,20 +941,20 @@
Modifier le serveur d’identité
Vous utilisez actuellement %1$s pour découvrir et être découvrable par les contacts existants que vous connaissez.
Vous n’utilisez actuellement aucun serveur d’identité. Pour découvrir et être découvrable par les contacts existants que vous connaissez, configurez-en un ci-dessous.
- Adresses e-mail découvrables
- Les options de découverte apparaîtront quand vous aurez ajouté un e-mail.
+ Adresses électronique découvrables
+ Les options de découverte apparaîtront quand vous aurez ajouté un courriel.
Les options de découverte apparaîtront quand vous aurez ajouté un numéro de téléphone.
- La déconnexion du serveur d’identité signifie que vous ne pourrez plus être découvrable par les autres utilisateurs et que vous ne pourrez plus inviter d’autres personnes par e-mail ou par téléphone.
+ La déconnexion du serveur d’identité signifie que vous ne pourrez plus être découvrable par les autres utilisateurs et que vous ne pourrez plus inviter d’autres personnes par courriel ou par téléphone.
Numéros de téléphone découvrables
- Nous vous avons envoyé un e-mail de confirmation à %s, consultez vos e-mails et cliquez sur le lien de confirmation
+ Nous vous avons envoyé un courriel de confirmation à %s, consultez vos courriels et cliquez sur le lien de confirmation
Renseignez l’URL d’un serveur d’identité
Impossible de se connecter au serveur d’identité
Veuillez renseigner l’URL du serveur d’identité
Le serveur d’identité n’a pas de conditions de service
Le serveur d’identité qui vous avez choisi n’a pas de conditions de service. Continuez uniquement si vous faites confiance au propriétaire de ce service
Un SMS a été envoyé à %s. Saisissez le code de vérification qu’il contient.
- Vous partagez actuellement des adresse e-mails et des numéros de téléphone sur le serveur d’identité %1$s. Vous devrez vous reconnecter à %2$s pour arrêter de les partager.
- Acceptez les conditions de service du serveur d’identité (%s) pour vous permettre d’être découvrable avec une adresse e-mail ou un numéro de téléphone.
+ Vous partagez actuellement des adresses électroniques et des numéros de téléphone sur le serveur d’identité %1$s. Vous devrez vous reconnecter à %2$s pour arrêter de les partager.
+ Acceptez les conditions de service du serveur d’identité (%s) pour vous permettre d’être découvrable avec une adresse électronique ou un numéro de téléphone.
Activer les journaux verbeux.
Les journaux verbeux aideront les développeurs en fournissant plus de journaux quand vous envoyez un rapport d’anomalie. Même si cette option est activée, l’application n’envoie pas le contenu des messages ou toute autre donnée personnelle.
Réessayez quand vous aurez accepté les termes et conditions de votre serveur d’accueil.
@@ -1049,7 +1049,7 @@
Étendez et personnalisez votre expérience
Démarrer
Sélectionner un serveur
- Tout comme les e-mails, les comptes ont un serveur d’accueil, même si vous pouvez parler à tout le monde
+ Tout comme les courriels, les comptes ont un serveur d’accueil, même si vous pouvez parler à tout le monde
Rejoignez des millions de personnes gratuitement sur le plus grand serveur public
Hébergement premium pour les organisations
En savoir plus
@@ -1075,20 +1075,20 @@
L’application ne peut pas créer de compte sur ce serveur d’accueil.
\n
\nVoulez-vous vous inscrire en utilisant un client web \?
- Cet e-mail n’est associé à aucun compte.
+ Ce courriel n’est associé à aucun compte.
Réinitialiser le mot de passe sur %1$s
- Un e-mail de vérification sera envoyé à votre adresse pour confirmer la configuration de votre nouveau mot de passe.
+ Un courriel de vérification sera envoyé à votre adresse pour confirmer la configuration de votre nouveau mot de passe.
Suivant
- E-mail
+ Courriel
Nouveau mot de passe
Attention !
Le changement de mot de passe réinitialisera toutes les clés de chiffrement sur toutes vos sessions, rendant l’historique des discussions chiffrées illisible. Configurez la sauvegarde de clés ou exportez vos clés de salon depuis une autre session avant de réinitialiser votre mot de passe.
Poursuivre
- Cet e-mail n’est lié à aucun compte
+ Ce courriel n’est lié à aucun compte
Vérifiez votre boîte de réception
- Un e-mail de vérification a été envoyé à %1$s.
+ Un courriel de vérification a été envoyé à %1$s.
Touchez le lien pour confirmer votre nouveau mot de passe. Après avoir suivi le lien qu’il contient, cliquez ci-dessous.
- J’ai vérifié mon adresse e-mail
+ J’ai vérifié mon adresse électronique
Succès !
Votre mot de passe a été réinitialisé.
Vous avez été déconnecté de toutes les sessions et ne recevrez plus de notification. Pour réactiver les notifications, reconnectez-vous sur chaque appareil.
@@ -1097,10 +1097,10 @@
Votre mot de passe n’a pas encore été changé.
\n
\nArrêter le processus de changement \?
- Définir l’adresse e-mail
- Définir une adresse e-mail pour récupérer votre compte. Plus tard, vous pourrez éventuellement autoriser des personnes à vous retrouver avec votre adresse e-mail.
- E-mail
- E-mail (facultatif)
+ Définir l’adresse électronique
+ Définir une adresse électronique pour récupérer votre compte. Plus tard, vous pourrez éventuellement autoriser des personnes à vous retrouver avec votre adresse électronique.
+ Courriel
+ Courriel (facultatif)
Suivant
Définir le numéro de téléphone
Définir un numéro de téléphone pour autoriser éventuellement des personnes à vous découvrir.
@@ -1116,7 +1116,7 @@
Les numéros de téléphone internationaux doivent commencer par « + »
Le numéro de téléphone n’a pas l’air d’être valide. Veuillez le vérifier
S’inscrire sur %1$s
- Nom d’utilisateur ou e-mail
+ Nom d’utilisateur ou courriel
Mot de passe
Suivant
Ce nom d’utilisateur est déjà pris
@@ -1129,8 +1129,8 @@
Sélectionner un serveur d’accueil personnalisé
Veuillez compléter le captcha
Acceptez les termes pour continuer
- Vérifiez vos e-mails
- Nous avons envoyé un e-mail à %1$s.
+ Vérifiez vos courriels
+ Nous avons envoyé un courriel à %1$s.
\nCliquez sur le lien qu’il contient pour continuer la création du compte.
Le code saisi n’est pas correct. Veuillez vérifier.
Serveur d’accueil obsolète
@@ -1184,7 +1184,7 @@
Préfixe ¯\\_(ツ)_/¯ à un message en texte brut
Activer le chiffrement
Une fois qu’il est activé, le chiffrement ne peut pas être désactivé.
- Le domaine de votre adresse e-mail n’est pas autorisé à s’inscrire sur ce serveur
+ Le domaine de votre adresse électronique n’est pas autorisé à s’inscrire sur ce serveur
Connexion non fiable
Ils correspondent
Ils ne correspondent pas
@@ -1443,7 +1443,7 @@
Message supprimé
Afficher les messages supprimés
Afficher un remplaçant pour les messages supprimés
- Nous vous avons envoyé un e-mail de confirmation à %s, consultez vos e-mails et cliquez sur le lien de confirmation
+ Nous vous avons envoyé un courriel de confirmation à %s, consultez vos courriels et cliquez sur le lien de confirmation
Le code de vérification n’est pas correct.
MÉDIA
Il n’y a aucun média dans ce salon
@@ -1466,7 +1466,7 @@
Cette opération n’est pas possible. Le serveur d’accueil est obsolète.
Veuillez d’abord configurer un serveur d’identité.
Veuillez d’abord accepter les termes du serveur d’identité dans les paramètres.
- Pour votre vie privée, ${app_name} prend uniquement en charge l’envoi des adresses e-mail et des numéros de téléphone hachés.
+ Pour votre vie privée, ${app_name} prend uniquement en charge l’envoi des adresses électronique et des numéros de téléphone hachés.
L’association a échoué.
Il n’y a actuellement aucune association avec cet identifiant.
Votre serveur d’accueil (%1$s) propose d’utiliser %2$s comme serveur d’identité
@@ -1653,8 +1653,8 @@
- %d utilisateur banni
- %d utilisateurs bannis
- Gérer les e-mails et numéros de téléphone liés à votre compte Matrix
- E-mails et numéros de téléphone
+ Gérer les courriels et numéros de téléphone liés à votre compte Matrix
+ Courriels et numéros de téléphone
Sauvegarde sécurisée
Démarrer la caméra
Ouvrir la discussion
@@ -1677,11 +1677,11 @@
- %d seconde
- %d secondes
- Assurez-vous d\'avoir cliqué sur le lien envoyé par e-mail.
+ Assurez-vous d\'avoir cliqué sur le lien envoyé par courriel.
Supprimer %s \?
Numéros de téléphone
- Aucune adresse e-mail n’a été ajoutée à votre compte
- Adresses e-mail
+ Aucune adresse électronique n’a été ajoutée à votre compte
+ Adresses électroniques
Aucun numéro de téléphone n’a été ajouté à votre compte
Filtrer les utilisateurs exclus
Ne plus ignorer cet utilisateur aura pour effet de ré-afficher ses messages.
@@ -1745,8 +1745,8 @@
%1$d de %2$d
Autoriser
Révoquer mon autorisation
- Vous avez donné votre autorisation pour envoyer des e-mails et des numéros de téléphone à ce serveur d’identité pour découvrir d\'autres utilisateurs à partir de vos contacts.
- Envoyer des e-mails et des numéros de téléphone
+ Vous avez donné votre autorisation pour envoyer des courriels et des numéros de téléphone à ce serveur d’identité pour découvrir d\'autres utilisateurs à partir de vos contacts.
+ Envoyer des courriels et des numéros de téléphone
Suggestions
Utilisateurs connus
code QR
@@ -1997,7 +1997,7 @@
Permettra de parcourir les salons de %s
Inviter dans %s
Partager le lien
- Inviter par e-mail
+ Inviter par courriel
Vous êtes seul pour l’instant. %s sera plus agréable avec de la compagnie.
Invitez des personnes dans votre espace
Inviter des personnes
@@ -2153,7 +2153,7 @@
Mentions et mots-clés
Notifications par défaut
%s dans les paramètres pour recevoir les invitations directement dans ${app_name}.
- Lier cet e-mail à votre compte
+ Lier ce courriel à votre compte
Cette invitation à cette espace a été envoyée à %s qui n’est pas associé à votre compte
Cette invitation à ce salon a été envoyée à %s qui n’est pas associé à votre compte
Tous les salons dans lesquels vous vous trouvez seront affichés sur l’Accueil.
@@ -2415,4 +2415,8 @@
Copier le lien du fil de discussion
Voir dans le salon
Voir les fils de discussions
+
+ - %d changement des ACL du serveur
+ - %d changements des ACL du serveur
+
\ No newline at end of file
diff --git a/vector/src/main/res/values-in/strings.xml b/vector/src/main/res/values-in/strings.xml
index d7856089e8..719b49fc4b 100644
--- a/vector/src/main/res/values-in/strings.xml
+++ b/vector/src/main/res/values-in/strings.xml
@@ -2002,7 +2002,7 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan.
\n${app_name} Desktop
Atur kata sandi akun baru…
Tidak dapat menyimpan file media
- Mengaktifkan pengaturan ini menambahkan FLAG_SECURE ke semua Aktifitas. Mulai ulang aplikasi ini untuk berpengaruh pada perubahannya.
+ Mengaktifkan pengaturan ini menambahkan FLAG_SECURE ke semua Aktivitas. Mulai ulang aplikasi ini untuk berpengaruh pada perubahannya.
Mencegah tangkapan layar dari aplikasi
Kunci pemulihan kunci cadangan
Tidak tahu Kunci Frasa Sandi Cadangan, Anda dapat %s.
diff --git a/vector/src/main/res/values-is/strings.xml b/vector/src/main/res/values-is/strings.xml
index 07fe4b55be..ee34c9e314 100644
--- a/vector/src/main/res/values-is/strings.xml
+++ b/vector/src/main/res/values-is/strings.xml
@@ -4,9 +4,9 @@
%1$s bauð %2$s
%1$s bauð þér
%1$s gekk í hópinn
- %1$s hætti
+ %1$s hætti í spjallrásinni
%1$s hafnaði boðinu
- %1$s sparkaði %2$s
+ %1$s fjarlægði %2$s
%1$s afbannaði %2$s
%1$s bannaði %2$s
%1$s breyttu auðkennismynd sinni
@@ -20,10 +20,10 @@
Villa í Matrix
Tölvupóstfang
Símanúmer
- %1$s tók til baka boð frá %2$s
+ %1$s tók til baka boð til %2$s
%1$s setti birtingarnafn sitt sem %2$s
%1$s breytti birtingarnafni sínu úr %2$s í %3$s
- %1$s fjarlægði birtingarnafn sitt (%2$s)
+ %1$s fjarlægði birtingarnafn sitt (sem var %2$s)
%1$s breytti umræðuefninu í: %2$s
%1$s breytti heiti spjallrásarinnar í: %2$s
%s hringdi myndsamtal.
@@ -38,7 +38,6 @@
Tæki sendandans hefur ekki sent okkur dulritunarlyklana fyrir þessi skilaboð.
Boð á spjallrás
%1$s og %2$s
-
Tóm spjallrás
Ljóst þema
Dökkt þema
@@ -80,7 +79,6 @@
Samtöl
Engar niðurstöður
Spjallrásir
-
Senda atvikaskrá
Senda hrunskrár
Senda skjámynd
@@ -121,14 +119,13 @@
Taka þátt
Hafna
Listi yfir meðlimi
-
- %d meðlimur
- %d meðlimir
Fara af spjallrás
- Ertu viss um að þú viljir fara út spjallrásinni?
- BEINT SPJALL
+ Ertu viss um að þú viljir fara úr spjallrásinni\?
+ Bein skilaboð
Bjóða
%s er að skrifa…
%1$s & %2$s eru að skrifa…
@@ -145,7 +142,6 @@
Leita
Sía meðlimi spjallrásar
Engar niðurstöður
-
Öll skilaboð
Notandamynd
Birtingarnafn
@@ -180,7 +176,7 @@
Veldu tungumál
Breyta lykilorði
eldra lykilorð
- nýtt lykilorð
+ Nýtt lykilorð
Mistókst að uppfæra lykilorð
Lykilorðið þitt hefur verið uppfært
Sýna öll skilaboð frá %s\?
@@ -199,10 +195,10 @@
Þema
Afkóðunarvilla
Heiti tækis
- Auðkenni tækis
+ Auðkenni setu
Dulritunarlykill tækis
Flytja út
- Settu inn lykilsetningu (passphrase)
+ Settu inn lykilsetningu
Staðfestu lykilsetningu
Flytja inn
Sannreyna
@@ -270,19 +266,18 @@
Þér hefur verið sparkað úr %1$s af %2$s
Þú hefur verið settur í bann á %1$s af %2$s
Tilraunir
- Klaga efni
+ Kæra efni
Slóð á heimaþjón
Ertu viss að þú viljir byrja raddsamtal?
Ertu viss að þú viljir byrja myndsamtal?
- Fara í fyrstu ólesin skilaboð.
+ Fara í ólesið
Banna
Afbanna
Fela öll skilaboð frá þessum notanda
Sýna öll skilaboð frá þessum notanda
- Þú hefur ekki heimild til að senda skilaboð á þessa spjallrás
+ Þú hefur ekki heimild til að senda skilaboð á þessa spjallrás.
Gat ekki sannreynt auðkenni fjartengds þjóns.
-
- Bæta við flýtileið á aðalskjá
+ Bæta við á upphafsskjá
Hljóð með tilkynningu
Virkja tilkynningar fyrir þennan notandaaðgang
Virkja tilkynningar á þessu tæki
@@ -294,7 +289,6 @@
Heimildir fyrir tengiliði
Alltaf birta tímamerki skilaboða
Birta tímamerki á 12 stunda sniði (t.d. 2:30 fh)
-
Heimaþjónn
Auðkennisþjónn
Þetta tölvupóstfang er nú þegar í notkun.
@@ -304,18 +298,16 @@
Einungis meðlimir (síðan þeir skráðu sig)
Innra auðkenni þessarar spjallrásar
Veldu skrá yfir spjallrásir
- URL-slóð heimaþjóns
+ Heiti heimaþjóns
- %d ólesið tilkynnt skilaboð
- %d ólesin tilkynnt skilaboð
-
Ertu viss um að þú viljir eyða viðmótshlutanum?
Gat ekki búið til viðmótshluta.
Mistókst að senda beiðni.
Boðið
- Bönun notanda mun henda þeim út úr þessu herbergi og halda þeim frá því að koma aftur.
-
+ Bann á notanda mun henda honum út af þessari spjallrás og koma í veg fyrir að viðkomandi komi aftur.
Skilaboð innihalda birtingarnafn mitt
Skilaboð innihalda notandanafn mitt
Skilaboð í maður-á-mann spjalli
@@ -332,10 +324,8 @@
Þú getur ekki afturkallað þessa aðgerð, þar sem þú ert að gefa notandanum jafn mikil völd og þú hefur sjálf/ur.
\nErtu alveg viss\?
Hlé milli tveggja samstillingarbeiðna
- Halda gögnum
+ Halda myndefni
Skoðaðu tölvupóstinn þinn og smelltu á tengilinn sem hann inniheldur. Þegar því er lokið skaltu smella á að halda áfram.
-
-
Aðeins dulrita til sannvottaðra tækja
Aldrei senda dulrituð skilaboð af þessu tæki til ósannvottaðra tækja.
Völd verða að vera jákvæð heiltala.
@@ -344,36 +334,1210 @@
Vantar spjallrásarauðkenni í beiðni.
Vantar notandaauðkenni í beiðni.
Senda límmerki
-
Ekki var svarað á fjartengda endanum.
-
-
${app_name} þarf heimild til að nota hljóðnemann svo hægt sé að hringja hljóðsímtöl.
-
${app_name} þarf heimild til að nota myndavélina og hljóðnemann svo hægt sé að hringja myndsímtöl.
\n
\nLeyfðu aðgang í næstu sprettgluggum til þess að geta hringt.
-
-
Gera notandaaðgang óvirkann
Gera notandaaðganginn minn óvirkann
Senda greiningargögn
Yfirfara núna
Gera notandaaðgang óvirkann
Gera notandaaðgang óvirkann
-
Lýstu villunni. Hvað varstu að gera? Hverju áttirðu von á? Hvað gerðist í raun?
Til að geta greint vandamál eru atvikaskrár þessa forrits sendar með þessari villuskýrslu. Ef þú vilt einungis senda textann hér fyrir ofan, taktu þá gátmerkið úr reitnum:
Það er eins og þú sért að hrista símann ákveðið. Myndirðu vilja senda villuskýrslu?
Forritið hrundi síðast. Myndirðu vilja senda inn villuskýrslu?
Senda límmerki
${app_name} safnar nafnlausum greiningargögnum til að gera okkur kleift að bæta forritið.
- Heimaskjár
+ Upphafsskjár
Festa spjallrásir með óskoðuðum tilkynningum
Festa spjallrásir með ólesnum skilaboðum
Sjálfgefið virkja forskoðun innfelldra vefslóða
Þetta eru eiginleikar á tilraunastigi sem gætu bilað á óvæntan hátt. Notist með varúð.
Setja sem aðalvistfang
- Ekki setja sem aðalvistfang
+ Ekki hafa sem aðalvistfang
Nauðsynlegt gildi vantar.
+ Þú getur slökkt á þessu hvenær sem er í stillingunum
+ Dulrituð skilaboð í hópaspjalli
+ Dulrituð skilaboð í maður-á-mann spjalli
+ Upphaf samstillingar:
+\nHleð inn samtölunum þínum
+\nÞetta getur tekið dálítinn tíma ef þú tekur þátt í mörgum spjallrásum
+ Aðeins fólk sem hefur verið boðið getur fundið og tekið þátt
+ Einka (einungis gegn boði)
+ Þú getur sýsla- með tilkynningar í %1$s.
+ Sjálfgefinn uppruni myndefnis
+ Sjálfgefin þjöppun
+ Sýslaðu með tölvupóstföng og símanúmer sem tengd eru við Matrix-aðganginn þinn
+ Tölvupóstföng og símanúmer
+ Lykilorðið er ekki gilt
+ Utanaðkomandi aðgerðasöfn
+ Hjálpaðu okkur að bæta ${app_name}
+ Sýsla með uppgötvunarstillingarnar þínar.
+ Smelltu á leskvittanir til að sjá ítarlegan lista.
+ Birta leskvittanir
+ Markdown-sníðing
+ Láttu aðra sjá að þú sért að skrifa.
+ Senda skriftilkynningar
+ Umsýsla dulritunarlykla
+ Dulrituð hópskilaboð
+ Dulrituð bein skilaboð
+ Bestun fyrir rafhlöðuendingu
+ Birting tilkynninga
+ Tilkynningar eru óvirkar í þessari setu.
+\nYfirfarðu stillingar í ${app_name}.
+ Tilkynningar eru virkar í þessari setu.
+ Tilkynningar eru óvirkar fyrir notandaaðganginn þinn.
+\nYfirfarðu stillingar aðgangsins.
+ Tilkynningar eru virkar fyrir notandaaðganginn þinn.
+ Tilkynningar eru óvirkar í kerfisstillingum.
+\nYfirfarðu kerfisstillingarnar.
+ Tilkynningar eru virkar í kerfisstillingum.
+ Í keyrslu (%1$d af %2$d)
+ Keyra prófanir
+ Greining á vandamálum
+ Leysa vandamál með tilkynningar
+ Stikkorð mega ekki innihalda \'%s\'
+ Stikkorð mega ekki byrja með \'.\'
+ Bæta við nýju stikkorði
+ Stikkorðin þín
+ Minnst á og stikkorð
+ Virkja tilkynningar í tölvupósti fyrir %s
+ Til að fá tilkynningar í tölvupósti, þarf að tengja tölvupóstfang við Matrix-aðganginn þinn
+ Tilkynning í tölvupósti
+ Mikilvægi tilkynninga eftir atburðum
+ Skráð út úr setunni!
+ Spjallrásin hefur verið yfirgefin!
+ Einungis þar sem er minnst á og stikkorð
+ Sía bannaða notendur
+ Úr spjallþræði
+ Ábending: Ýttu lengi á skilaboð og notaðu “%s”.
+ Spjallþræðir hjálpa til við að halda samræðum við efnið og gerir auðveldara að rekja þær.
+ Haltu umræðum skipulögðum með spjallþráðum
+ Birtir alla spjallþræði sem þú hefur tekið þátt í
+ Birtir alla spjallþræði úr fyrirliggjandi spjallrás
+ Spjallþræðirnir mínir
+ Allir spjallþræðir
+ Sía þræði spjallrásar
+ Heimildir spjallrásar
+ Þessi spjallrás er ekki opinber. Þú munt ekki geta tekið aftur þátt nema að vera boðið.
+ Gefa heimild til að fá aðgang að tengiliðunum þínum.
+ Til að skanna QR-kóða þarftu að veita aðgang að myndavélinni.
+ Lýk símtali…
+ Notandi upptekinn
+ Þú settir símtalið í bið
+ %s setti símtalið í bið
+ Raddsímtal við %s
+ Myndsamtal við %s
+ Myndsamtal í gangi…
+
+ - Ósvarað myndsímtal
+ - %d ósvöruð myndsímtöl
+
+
+ - Ósvarað raddsímtal
+ - %d ósvöruð raddsímtöl
+
+ Veldu hringitón fyrir símtöl:
+ Innhringitónn
+ Nota sjálfgefinn ${app_name} hringitón fyrir innhringingar
+ Biðja um staðfestingu áður en símtal er hafið
+ Koma í veg fyrir símtöl af slysni
+ Þetta símanúmer er nú þegar skráð.
+ Því miður, ekkert utankomandi forrit hefur fundist sem getur lokið þessari aðgerð.
+ Í augnablikinu ertu ekki með neina límmerkjapakka virkjaða.
+\n
+\nBæta einhverjum við núna\?
+ Veldu hljóðtæki
+ ${app_name} símtal mistókst
+ Senda tal
+ Slóð á API-kerfisviðmót heimaþjóns
+ Ef mögulegt, skaltu skrifa lýsinguna á ensku.
+ Sýna allar spjallrásir í spjallrásalistanum, þar með taldar spjallrásir með viðkvæmu efni.
+ Sýna spjallrásir með viðkvæmu efni
+ Aðvaranir kerfis
+ Afrita tengil á spjallþráð
+ Taka úr birtingu
+ Skoða spjallþræði
+ Mistókst að fjarlægja viðmótshluta
+ Mistókst að bæta við viðmótshluta
+ Þú getur ekki byrjað símtal með sjálfum þér, bíddu eftir að þátttakendur samþykki boðið
+ Þú getur ekki byrjað símtal með sjálfum þér
+ Til að senda talskilaboð þarf að gefa heimild fyrir hljóðnema.
+ Til að framkvæma þessa aðgerð þarf að gefa heimild fyrir myndavél í kerfisstillingum.
+ Það vantar heimildir til að framkvæma þessa aðgerð, það þarf að gefa viðkomandi heimildir í kerfisstillingum.
+ Hefja talfund
+ Hefja myndfund
+ Þú hefur ekki heimildir til að hefja símtal
+ Þú hefur ekki heimildir til að hefja símtal á þessari spjallrás
+ Þú hefur ekki heimildir til að hefja fjarfund
+ Þú hefur ekki heimildir til að hefja fjarfund á þessari spjallrás
+ Vantar heimildir
+ Þú munt missa aðgang að dulrituðu skilaboðunum þínum nema þú takir öryggisafrit af dulritunarlyklum áður en þú skráir þig út.
+ Öryggisafritun dulritunarlykla í gangi. Þú munt tapa dulrituðu skilaboðunum þínum ef þú skráir þig út núna.
+ Þú munt tapa dulrituðu skilaboðunum þínum ef þú skráir þig út núna
+ Þú kveiktir á enda-í-enda dulritun.
+ %1$s kveikti á enda-í-enda dulritun.
+ Taka öryggisafrit
+ Öryggisafrita dulritunarlykla…
+ Ég vil ekki dulrituðu skilaboðin mín
+ Nota öryggisafrit af lykli
+ Hlusta eftir tilkynningum
+ Þú kveiktir á enda-í-enda dulritun (óþekkt algrími %1$s).
+ %1$s kveikti á enda-í-enda dulritun (óþekkt algrími %2$s).
+ Þú hefur bannað gestum að koma inn á spjallrásina.
+ %1$s hefur bannað gestum að koma inn á spjallrásina.
+ Þú hefur bannað gestum að koma inn á spjallrásina.
+ %1$s hefur bannað gestum að koma inn á spjallrásina.
+ Þú hefur leyft gestum að koma inn hér.
+ %1$s hefur leyft gestum að koma inn hér.
+ Þú hefur leyft gestum að koma inn á spjallrásina.
+ %1$s hefur leyft gestum að koma inn á spjallrásina.
+
+ - Þú fjarlægðir varavistfangið %1$s af þessari spjallrás.
+ - Þú fjarlægðir varavistföngin %1$s af þessari spjallrás.
+
+
+ - %1$s fjarlægði varavistfangið %2$s af þessari spjallrás.
+ - %1$s fjarlægði varavistföngin %2$s af þessari spjallrás.
+
+
+ - Þú bættir við varavistfanginu %1$s fyrir þessa spjallrás.
+ - Þú bættir við varavistföngunum %1$s fyrir þessa spjallrás.
+
+
+ - %1$s bætti við varavistfanginu %2$s fyrir þessa spjallrás.
+ - %1$s bætti við varavistföngunum %2$s fyrir þessa spjallrás.
+
+ Þú tókst til baka boð til %1$s. Ástæða: %2$s
+ %1$s tók til baka boð til %2$s. Ástæða: %3$s
+ Þú samþykktir boð um að taka þátt í %1$s. Ástæða: %2$s
+ %1$s samþykkti boð um að taka þátt í %2$s. Ástæða: %3$s
+ Þú bannaðir %1$s. Ástæða: %2$s
+ %1$s bannaði %2$s. Ástæða: %3$s
+ Þú tókst %1$s úr banni. Ástæða: %2$s
+ %1$s tók %2$s úr banni. Ástæða: %3$s
+ Þú fjarlægðir %1$s. Ástæða: %2$s
+ %1$s fjarlægði %2$s. Ástæða: %3$s
+ Þú hafnaðir boðinu. Ástæða: %1$s
+ %1$s hafnaði boðinu. Ástæða: %2$s
+ Þú hættir. Ástæða: %1$s
+ %1$s hætti. Ástæða: %2$s
+ Þú yfirgafst spjallrásina. Ástæða: %1$s
+ %1$s yfirgaf spjallrásina. Ástæða: %2$s
+ Þú tekur þátt. Ástæða: %1$s
+ %1$s tekur þátt. Ástæða: %2$s
+ Þú komst inn á spjallrásina. Ástæða: %1$s
+ %1$s kom inn á spjallrásina. Ástæða: %2$s
+ %1$s bauð þér. Ástæða: %2$s
+ Þú bauðst %1$s. Ástæða: %2$s
+ %1$s bauð %2$s. Ástæða: %3$s
+ Boð um þátttöku til þín. Ástæða: %1$s
+ Boð um þátttöku til %1$s. Ástæða: %2$s
+ Upphaf samstillingar:
+\nFlyt inn gögn úr notandaaðgangi
+ Upphaf samstillingar:
+\nFlyt inn samfélög
+ Upphaf samstillingar:
+\nFlyt inn yfirgefnar spjallrásir
+ Upphaf samstillingar:
+\nFlyt inn boð í spjallrásir
+ Upphaf samstillingar:
+\nFlyt inn spjallrásir
+ Upphaf samstillingar:
+\nFlyt inn dulritunargögn
+ Upphaf samstillingar:
+\nFlyt inn notandaaðgang…
+ Upphaf samstillingar:
+\nSæki gögn…
+ Upphaf samstillingar:
+\nBíð eftir svari frá netþjóni…
+ Þú hefur ekki heimild til að taka þátt í þessari spjallrás
+ Þú breyttir völdum %1$s.
+ Þú breyttir %1$s viðmótshluta
+ %1$s breytti %2$s viðmótshluta
+ Þú fjarlægðir %1$s viðmótshluta
+ %1$s fjarlægði %2$s viðmótshluta
+ Þú bættir við %1$s viðmótshluta
+ %1$s bætti við %2$s viðmótshluta
+ Þú samþykktir boð um að taka þátt í %1$s
+ Þú afturkallaðir boðið til %1$s
+ %1$s afturkallaði boðið til %2$s
+ Þú afturkallaðir boð til %1$s um þátttöku í spjallrásinni
+ %1$s afturkallaði boð til %2$s um þátttöku í spjallrásinni
+ Þú bauðst %1$s
+ Þú sendir boð til %1$s um þátttöku í spjallrásinni
+ Þú fjarlægðir auðkennismynd spjallrásarinnar
+ %1$s fjarlægði auðkennismynd spjallrásarinnar
+ Þú fjarlægðir umfjöllunarefni spjallrásar
+ Þú fjarlægðir heiti spjallrásar
+
+ - Breyting á ACL á %d netþjóni
+ - Breyting á ACL á %d netþjóni
+
+ Þú gerðir skilaboð héðan í frá sýnileg fyrir %1$s
+ Bæta við fólki
+ 🎉 Öllum netþjónum er núna bannað að taka þátt! Þessa spjallrás er ekki lengur hægt að nota.
+ • Netþjónar sem samsvara IP-tölum eru núna bannaðir.
+ • Netþjónar sem samsvara IP-tölum eru núna leyfðir.
+ • Netþjónar sem samsvara %s voru fjarlægðir af listanum yfir leyfilegt.
+ • Netþjónar sem samsvara %s eru núna leyfðir.
+ • Netþjónar sem samsvara %s voru fjarlægðir af bannlistanum.
+ • Netþjónar sem samsvara %s eru núna bannaðir.
+ Þú breyttir ACL á netþjóni fyrir þessa spjallrás.
+ %s breytti ACL á netþjóni fyrir þessa spjallrás.
+ • Netþjónar sem samsvara IP-tölum eru bannaðir.
+ • Netþjónar sem samsvara IP-tölum eru leyfðir.
+ • Netþjónar sem samsvara %s eru leyfðir.
+ • Netþjónar sem samsvara %s eru bannaðir.
+ Þú stilltir ACL á netþjóni fyrir þessa spjallrás.
+ %s stillti ACL á netþjóni fyrir þessa spjallrás.
+ Þú uppfærðir hér.
+ %s uppfærði hér.
+ Þú uppfærðir þessa spjallrás.
+ %s uppfærði þessa spjallrás.
+ %1$s gerði skilaboð héðan í frá sýnileg fyrir %2$s
+ Þú gerðir ferilskrá spjallrásar héðan í frá sýnilega fyrir %1$s
+ Þú laukst símtalinu.
+ Þú svaraðir símtalinu.
+ Þú sendir gögn til að setja upp samtalið.
+ %s sendi gögn til að setja upp samtalið.
+ Þú hringdir raddsamtal.
+ Þú hringdir myndsamtal.
+ Þú breyttir heiti spjallrásarinnar í: %1$s
+ Þú breyttir auðkennismynd spjallrásarinnar
+ %1$s breytti auðkennismynd spjallrásarinnar
+ Þú breyttir umræðuefninu í: %1$s
+ Þú fjarlægðir birtingarnafn þitt (sem var %1$s)
+ Þú breytti birtingarnafni þínu úr %1$s í %2$s
+ Þú settir birtingarnafn þitt sem %1$s
+ Opna könnun
+ Spila talskilaboð
+ Þú þarft heimild til að uppfæra spjallrás
+ Vertu þolinmóð/ur Þetta getur tekið nokkra stund.
+ Opið öllum, best fyrir dreifða hópa
+ Aðvara án hljóðs
+ Aðvara með hljóði
+ Opna emoji-tánmyndaval
+ Skipta um auðkennismynd
+ Opna viðmótshluta
+ Virkt samtal (%1$s)
+ Talnaborð
+ Nýtt PIN-númer
+ Opna notkunarskilmála %s
+ Önnur tiltæk tungumál
+ 🔐️ Vertu með mér á ${app_name}
+ Veldu þér lykilorð.
+ Veldu þér notandanafn.
+ Aðeins stutt í dulrituðum spjallrásum
+ Settu inn endurheimtulykil
+ Ný innskráning. Varst þetta þú\?
+ Flugvélahamur er virkur
+ Ekki treyst
+ Sérsniðið (%1$d) í %2$s
+ Sjálfgefið í %1$s
+ Umsjónarmaður í %1$s
+ Stjórnandi í %1$s
+ Stjórnendur
+
+ - Einn aðili
+ - %1$d aðilar
+
+ Aðgerðir stjórnanda
+ Ekki öruggt
+ Birta villuleitarupplýsingar á skjá
+ Aðrar setur
+ Úreltur heimaþjónn
+ Athugaðu tölvupóstinn þinn
+ Símanúmer lítur út fyrir að vera ógilt. Yfirfarðu það
+ Nota alþjóðlega sniðið.
+ Nýtt lykilorð
+ Opna valmyndina til að útbúa spjallrás
+ Settu inn slóð auðkennisþjónsins
+ Engar breytingar fundust
+ Aðrar tilkynningar frá utanaðkomandi aðilum
+ Ekkert netkerfi. Athugaðu nettenginguna þína.
+ Atburður undir umsjón stjórnanda spjallrásar
+ Eyða öryggisafriti
+ Öryggisafrit endurheimti %s !
+ Settu inn endurheimtulykil
+ Aflæsi ferli
+ Flyt inn dulritunarlykla…
+ Næ í dulritunarlykla…
+ Reikna endurheimtulykil…
+ Endurheimti úr öryggisafriti:
+ Settu inn endurheimtulykil
+ Endurheimtulykill
+ Deila endurheimtulykli með…
+ Gera afrit
+ Endurheimtulykillinn hefur verið vistaður.
+ Vista endurheimtulykil
+ Ég hef gert afrit
+ Því miður, villa kom upp
+ Smelltu hér til að sjá eldri skilaboð
+ Markdown-texti hefur verið gerður óvirkur.
+ Markdown-texti hefur verið gerður virkur.
+ Birtir upplýsingar um notanda
+ Markdown-texti af/á
+ Skilgreindu völd notanda
+ Loka á allt
+ Auðkenni viðmótshluta
+ Þemað þitt
+ Notandaauðkennið þitt
+ Vefslóð á auðkennismyndina þína
+ Birtingarnafnið þitt
+ Þessum viðmótshluta var bætt við af:
+ %1$s: %2$s %3$s
+ ** Mistókst að senda - opnaðu spjallrásina
+ %1$s í %2$s og %3$s
+
+ - %1$s: %2$d skilaboð
+ - %1$s: %2$d skilaboð
+
+
+ - %d boð
+ - %d boð
+
+ Þessi netþjónn er nú þegar á listanum
+ Fann ekki þennan netþjón eða spjallrásalista hans
+ Sláðu inn nafn nýja netþjónsins sem þú vilt skoða.
+ óþekkt IP-vistfang
+ Sýsla með öryggisafrit dulritunarlykla
+ Endurheimt dulritaðra skilaboða
+ Útflutningur dulritunarlykla tókst
+ Önnur svæði sem þú gætir ekki vitað um
+ Spila hljóð við myndatöku
+ Engin samstilling í bakgrunni
+ Bestað gagnvart rauntíma
+ Bestað gagnvart rafhleðslu
+ Hamur samstillingar í bakgrunni
+ Láta mig vita fyrir
+ Engu tölvupóstfangi hefur verið bætt við notandaaðganginn þinn
+ Engu símanúmeri hefur verið bætt við notandaaðganginn þinn
+ Mistókst að koma á rauntímatengingu.
+\nBiddu kerfisstjóra heimaþjónsins þíns um að setja upp TURN-þjón til að tryggja að símtöl virki eðlilega.
+ %1$s úr %2$s í %3$s
+ %1$s breytti völdum %2$s.
+ Stjórnandi
+ Þú breyttir auðkennismyndinni þinni
+ Þú tókst til baka boð til %1$s
+ Þú bannaðir %1$s
+ Þú afbannaðir %1$s
+ Þú fjarlægðir %1$s
+ Þú hafnaðir boðinu
+ Þú hættir í spjallrásinni
+ %1$s hætti í spjallrásinni
+ Þú hættir í spjallrásinni
+ Þú gekkst í hópinn
+ Þú gekkst í spjallrásina
+ Þú bauðst %1$s
+ Þú bjóst til umræðuna
+ %1$s bjó til umræðuna
+ Þú bjóst til spjallrásina
+ %1$s bjó til spjallrásina
+ %s gekk í hópinn.
+ Þú stilltir aðalvistfang spjallrásarinnar sem %1$s.
+ Sérsniðin kæra…
+ Sýna allar spjallrásir á forsíðu
+ Sýsla með spjallrásir og svæði
+ Sýsla með spjallrásir
+ Svæði eru ný leið til að hópa fólk og spjallrásir.
+ Bæta við fyrirliggjandi svæðum
+ Bæta við fyrirliggjandi spjallrásum
+ Yfirgefa svæði
+ Bæta við spjallrásum
+ Kanna spjallrásir
+ Búa til svæði
+ Ég og félagar í teyminu mínu
+ Bara ég
+ Einkasvæðið þitt
+ Opinbera svæðið þitt
+ Bæta við svæði
+ Einkasvæði
+ Opinbert svæði
+ Uppfærir spjallrás í nýja útgáfu
+ Búa til svæði
+ Almenningsspjallrás
+ Eyða auðkennismynd
+ Það kom upp villa við að fletta upp símanúmerinu
+ Sendir skilaboðið með snjókomu
+ Sendir skilaboðið með skrauti
+ Uppfærsla dulritunar tiltæk
+ Sendir skilaboð sem óbreyttur texti án þess að túlka það sem markdown
+ Dulritun ekki virk
+ Skilaboð í þessari spjallrás eru enda-í-enda dulrituð.
+ Kerfisstjóri netþjónsins þíns hefur lokað á sjálfvirka dulritun í einkaspjallrásum og beinum skilaboðum.
+ Stillingar spjallrásar
+ Skilaboð í þessari spjallrás eru ekki enda-í-enda dulrituð.
+ Límmerki
+ Útbý svæði…
+ Settu inn vistfang spjallrásar
+ Þetta vistfang er nú þegar í notkun
+ Vistfang svæðis
+ Eftir að kveikt er á dulritun er ekki hægt að slökkva á henni.
+ Setur ( ͡° ͜ʖ ͡°) framan við hrein textaskilaboð
+ Setur ¯\\_(ツ)_/¯ framan við hrein textaskilaboð
+ Þetta lítur ekki út eins og gilt tölvupóstfang
+ Skrá tölvupóstfang
+ Sláðu inn vistfang netþjónsins sem þú vilt nota
+ Sláðu inn vistfang Modular Element-þjóns eða netþjónsins sem þú vilt nota
+ Vistfang fyrir Element Matrix þjónustur
+ Skrá inn í %1$s
+ Tengjast við %1$s
+ Skrá inn með %s
+ Teymi
+ Vinir og fjölskylda
+ Sendir skilaboðin sem stríðni
+ Stillingar spjallrásar
+ Hunsa notanda
+ Snúa og skera utan af
+ Límmerki
+ Bæta við mynd frá
+ Búa til nýja spjallrás
+ Samþykktu þjónustuskilmála auðkennisþjónsins (%s) svo hægt sé að finna þig með tölvupóstfangi eða símanúmeri.
+ Þú ert núna að deila tölvupóstföngum eða símanúmerum á auðkennisþjóninum %1$s. Þú þarft að tengjast aftur við %2$s til að hætta að deila þessu.
+ Finnanleg tölvupóstföng
+ Búa til nýja spjallrás
+ Notaðu vélmenni, viðmótshluta og límmerkjapakka
+ Sýsla með samþættingar
+ Leyfðu \'Sýsla með samþættingar\' í stillingunum til að gera þetta.
+ Samþættingar eru óvirkar
+ Samþættingarstýring
+ Leyfa samþættingar
+ Sýna lyklaborð með tjáningartáknum
+ Senda skilaboð með \'Enter\'
+ Hefur ekki áhrif á boð/fjarlægingu/bönn.
+ Birta taka-þátt og hætta skilaboð
+ Notaðu samþættingarstýringu til að stýra vélmennum, viðmótshlutum og límmerkjapökkum.
+\nSamþættingarstýringar taka við stillingagögnum og geta breytt viðmótshlutum, sent boð í spjallrásir, auk þess að geta úthlutað völdum fyrir þína hönd.
+ Samþættingar
+ Þetta er upphaf ferils beinna skilaboða með %s.
+ Bein skilaboð
+ Leita að heiti
+ Leita eftir heiti, auðkenni eða tölvupóstfangi
+ Nafn eða auðkenni (#example:matrix.org)
+ Skoða spjallrásalistann
+ Senda ný bein skilaboð
+ Breytingar á skilaboðum
+ Þjappa myndskeiði %d%%
+ Þjappa mynd…
+ Sendi skrá (%1$s / %2$s)
+ Dulrita skrá…
+ Öll samfélög
+ Sýna fjarlægð skilaboð
+ Þú átt engin fleiri ólesin skilaboð
+ Boðið af %s
+ Sendi þér boð
+ Svara í spjallþræði
+ Allir lyklar öryggisafritaðir
+ Setja upp á þessu tæki
+ Varið öryggisafrit
+ Búa til svæði
+ Einungis gegn boði, best fyrir þig og lítinn hóp
+ Fara í fyrstu leskvittun
+ Sannprófa þessa setu
+ Þau samsvara ekki
+ Þau samsvara
+ Fela ítarlegt
+ Birta ítarlegt
+ Hreinsa öll gögn
+ Þú hefur verið skráður út úr öllum setum og munt ekki lengur fá ýti-tilkynningar. Til að endurvirkja tilkynningar, þarf að skrá sig aftur inn á hverju tæki fyrir sig.
+ Eigðu samtölin þín.
+ Aftengja auðkennisþjón
+ Umsagnir um svæði
+ Birta frátökutákn fyrir fjarlægð skilaboð
+ Eftir að þetta hefur verið virkjað, muntu geta sent staðsetninguna þína á hvaða spjallrás sem er
+ Niðurstöður birtast einungis eftir að þú hefur lokað könnuninni
+ Kjósendur sjá niðurstöðurnar þegar þeir hafa kosið
+
+ - Lokaniðurstöður byggðar á %1$d atkvæði
+ - Lokaniðurstöður byggðar á %1$d atkvæðum
+
+
+ - %1$d atkvæði greitt. Greiddu atkvæði til að sjá útkomuna
+ - %1$d atkvæði greidd. Greiddu atkvæði til að sjá útkomuna
+
+ Næ ekki að tengjast heimaþjóni á þessari slóð, athugaðu slóðina
+ réttur valkostur
+ Spurning eða viðfangsefni
+ Endurræstu forritið til að breytingin taki gildi.
+ Virkja LaTeX-stærðfræði
+ Tengja þetta tölvupóstfang við notandaaðganginn þinn
+
+ - %1$d til viðbótar
+ - %1$d til viðbótar
+
+ Birta skilaboðablöðrur
+ Mistókst að hlaða inn landakorti
+ Myndgera staðsetningu notenda á tímalínunni
+ Virkja deilingu staðsetninga
+ ${app_name} gat ekki fengið staðsetninguna þína. Reyndu aftur síðar.
+ ${app_name} gat ekki fengið staðsetninguna þína
+ Lokuð könnun
+ Ljúka könnun
+ Ljúka þessari könnun\?
+ Ljúka könnun
+ Engin atkvæði greidd
+
+ - Byggt á %1$d atkvæði
+ - Byggt á %1$d atkvæðum
+
+
+ - %1$d atkvæði
+ - %1$d atkvæði
+
+
+ - Það þarf allavega %1$s valkost
+ - Það þarf allavega %1$s valkosti
+
+ Spurning má ekki vera auð
+ Set upp öryggisafrit af lykli
+ Útbý öruggislykil úr lykilsetningu
+ Lykilsetning endurheimtu
+ Notaðu lykilsetningu endurheimtu eða dulritunarlykil
+ Sýsla með í öryggisafriti dulritunarlykla
+ Nota öryggisafrit af lykli
+ Verja öryggisafrit
+ Eyða öryggisafriti
+ Athuga ástand öryggisafrits
+ Eyði öryggisafriti…
+ Öryggisafrit af lyklum er ekki virkt í þessari setu.
+
+ - %d nýjum lykli hefur verið bætt við þessa setu.
+ - %d nýjum lyklum hefur verið bætt við þessa setu.
+
+
+ - Endurheimti öryggisafrit með %d lykli.
+ - Endurheimti öryggisafrit með %d lyklum.
+
+ Ef þú veist ekki lykilsetningu fyrir endurheimtu, geturðu %s.
+ notað endurheimtulykilinn þinn
+ Öryggisafrit er þegar til staðar á heimaþjóninum þínum
+ (Ítarlegt) Settu upp með endurheimtulykli
+ Bý til öryggisafrit
+ Stilla lykilsetningu
+ Verðu öryggisafritið þitt með lykilsetningu.
+ Byrja að nota öryggisafrit dulritunarlykla
+ Lykilsetning er of veik
+ Settu inn lykilsetningu
+ Lykilsetningar samsvara ekki
+ Búa til lykilsetningu
+ Útbúðu lykilsetningu til að dulrita útfluttu dulritunarlyklana. Þú þarft að setja inn sama lykilsetningu til að geta flutt aftur inn þessa dulritunarlykla.
+ Renna til að ljúka símtalinu
+ Tapaðu aldrei dulrituðum skilaboðum
+ Endilega %s til að halda áfram að nota þessa þjónustu.
+ Endilega %s til að fá þessi takmörk hækkuð.
+ Þessi heimaþjónn er kominn fram yfir takmörk á mánaðarlega virkum notendum.
+ Þessi heimaþjónn er kominn fram yfir takmörk á mánaðarlega virkum notendum þannig að sumir notendur munu ekki geta skráð sig inn.
+ Þessi heimaþjónn er kominn fram yfir takmörk á tilföngum sínum.
+ Þessi heimaþjónn er kominn fram yfir takmörk á tilföngum sínum þannig að sumir notendur munu ekki geta skráð sig inn.
+ hafðu samband við kerfisstjóra þjónustunnar þinnar
+ Þessi spjallrás er framhald af öðru samtali
+ Samtalið heldur áfram hér
+ Þessari spjallrás hefur verið skipt út og er hún ekki lengur virk.
+ Til að halda áfram að nota %1$s heimaþjóninn þarftu að yfirfara og samþykkja skilmálana og kvaðir.
+ Til að laga umsýslu Matrix-forrita
+ Beiðni um deilingu dulritunarlykils
+ Þú verður að samþykkja þjónustuskilmálana til að geta haldið áfram.
+ Ræstu myndavél kerfisins í stað sérsniðna myndavélaskjásins.
+ Þessi viðmótshluti vill nota eftirfarandi tilföng:
+ Fara af fyrirliggjandi fjarfundi og skipta yfir í hinn\?
+ Því miður, villa kom upp við að reyna að tengjast fjarfundinum
+ Því miður, fjarfundasímtöl með Jitsi eru ekki studd á eldri tækjum (tæki með Android OS minna en 6.0)
+ Afturkalla aðgang fyrir mig
+ Mistókst að hlaða inn viðmótshluta.
+\n%s
+ Að nota það gæti deilt gögnum með %s:
+ Að nota það gæti stillt vefkökur og deilt gögnum með %s:
+ Ef þetta samsvarar ekki, getur verið að samskiptin þín séu berskjölduð.
+ Staðfestu með því að bera eftirfarandi saman við \'Stillingar notanda\' í hinni setunni þinni:
+
+ - Tókst að flytja inn%1$d/%2$d dulritunarlykli.
+ - Tókst að flytja inn%1$d/%2$d dulritunarlyklum.
+
+ Veldu hvaða svæði hafa aðgang að þessari spjallrás. Ef svæði er valið geta meðlimir þess fundið og tekið þátt í spjallrásinni.
+ Hver sem er á svæði með þessari spjallrás getur fundið hana og tekið þátt í henni. Aðeins stjórnendur spjallrásarinnar geta bætt henni í svæði.
+ Hver sem er getur látið vita af sér á spjallrásinni, meðlimir geta þá samþykkt eða hafnað
+ Mistókst að fá sýnileika spjallrásar á spjallrásaskrá (%1$s).
+ Svæði sem þú veist að innihalda þessa spjallrás
+ Veldu hverjir geta fundið spjallrásina og tekið þátt.
+ Svæði sem hafa aðgang
+ Leyfa meðlimum svæðis að finna og fá aðgang.
+ Meðlimir svæðisins %s geta fundið, forskoðað og tekið þátt.
+ Óþekkt aðgangsstilling (%s)
+ Birta þessa spjallrás opinberlega á skrá %1$s yfir spjallrásir\?
+ Einungis meðlimir svæðis
+ Hver sem er getur fundið svæðið og tekið þátt
+ Hver sem er getur fundið spjallrásina og tekið þátt
+ Birta falda atburði í tímalínu
+ Hjálp og um
+ Rödd og myndband
+ Stillingar spjallrásar
+ Umfjöllunarefni spjallrásar (valkvætt)
+ Skipta um netkerfi
+ Búa til nýtt svæði
+ Viðbrögð
+ Skoða viðbrögð
+ Bæta við viðbrögðum
+ Viðbrögð
+ Þú hefur klárað að lesa allt!
+ Skoða á spjallrás
+ Breytir auðkennismyndinni þinni einungis í fyrirliggjandi spjallrás
+ Breytir auðkennismyndinni einungis í fyrirliggjandi spjallrás
+ Breytir birtu gælunafni þínu einungis í fyrirliggjandi spjallrás
+ Fjarlægir notanda með uppgefið auðkenni úr þessari spjallrás
+ Stilla umfjöllunarefni spjallrásar
+ Gengur til liðs við spjallrás með uppgefnu vistfangi
+ Býður notanda með uppgefið auðkenni í fyrirliggjandi spjallrás
+ Stillir heiti spjallrásar
+ Hunsar notanda, felur skilaboð viðkomandi fyrir þér
+ Bannar notanda með uppgefið auðkenni
+ Engir virkir viðmótshlutar
+ Nota hljóðnemann
+ Nota myndavélina
+ Hlaða inn viðmótshluta
+ Nýtt boð
+ Netþjónninn þinn
+ Útgáfa spjallrásar
+
+ - %d bannaður notandi
+ - %d bannaðir notendur
+
+ %1$s, %2$s, %3$s og %4$s
+ Villa í SSL.
+ Veldu heimaþjón
+ Skrá inn með einfaldri innskráningu (single sign-on)
+ Nota sem sjálfgefið og ekki spyrja aftur
+ Kveikja á HD
+ Slökkva á HD
+ Skipta á milli myndavéla
+ Þráðlaus heyrnartól
+ Tilkynning á spjallrás
+ Notendur
+ Tilkynna öllum á spjallrásinni
+ Sýna minna
+ Deila staðsetningu
+ Búa til könnun
+ Opna tengiliði
+ Senda límmerki
+ Hlaða inn skrá
+ Senda myndir og myndskeið
+ Opna myndavél
+ Opna með
+ Deila staðsetningu
+ Landakort
+ Deila staðsetningu
+ Staðsetning
+ Deila staðsetningu
+ Tegund könnunar
+ Breyta könnun
+ Fjarlægja könnun
+ Atkvæði greitt
+ ÚTBÚA KÖNNUN
+ BÆTA VIÐ VALKOSTI
+ Búa til valkosti
+ Búa til könnun
+ %1$ds eftir
+ Eyða upptöku
+ Stöðva upptöku
+ Uppfærsla er nauðsynleg
+ Uppfæra
+ Nafnlaus spjallrás
+ Tillaga
+ Ljúka uppsetningu
+ Sleppa í bili
+ Deila tengli
+ Bjóða fólki
+ Lýsing
+ Slembið
+ Almennt
+ Einka
+ Opinbert
+ Eyða ósendum skilaboðum
+ Mistókst
+ Sent
+ Sendi
+ Atburður sendur!
+ Ekkert efni
+ Stöðulykill
+ Tegund
+ Senda sérsniðinn atburð
+ Skoða stöðu spjallrásar
+ Ekki tiltækt
+ Ónettengt
+ Nettengt
+ Ekki tilkynna
+ Ekki skoðað
+ Athugað
+ Valið
+ Myndskeið
+ Mynd
+ Skjámynd
+ Tókst ekki að auðkenna
+ Óþekktur einstaklingur
+ Notendur
+ Flutningur
+ Tengjast
+ Virkt samtal (%1$s) ·
+
+ - Virkt samtal ·
+ - %1$d virk samtöl ·
+
+ Ekkert svar
+ Innhringing myndsamtals
+ Innhringing raddsamtals
+ Hringja til baka
+ Þessu símtali er lokið
+ Henda breytingum
+ Breyta PIN-númeri
+ Virkja PIN-númer
+ Gleymt PIN-númer\?
+ Settu inn PIN-númerið þitt
+ Staðfestu PIN-númer
+
+ - %d færsla
+ - %d færslur
+
+ Tengiliðaskrá
+ KANNA NÁNAR
+ NÁÐI ÞVÍ
+ Stilla auðkennismynd
+ Umfjöllunarefni
+ Nafn spjallrásar
+ Setja upp
+ Ræsa myndavélina
+ Stöðva myndavélina
+ Kveikja á hljóðnema
+ Þagga niður í hljóðnema
+ Opna spjall
+ Hlutverk
+ Stilla hlutverk
+ Senda inn
+ Nota %1$s
+ Núverandi tungumál
+ Bjóða vinum
+ Bjóða notendum
+ BJÓÐA
+ Ódulritað
+ Frumstilla allt
+ Gat ekki vistað myndefnisskrá
+ Skilaboð…
+ Leysa vandamál
+ "Umfjöllunarefni: "
+ Dulritun virk
+ Ljúka
+ Hætt við staðfestingu
+ Endurlesa
+ Staðfesta fjarlægingu
+ Fjarlægja…
+ Tenging við netþjón hefur rofnað
+ Nei
+ Já
+ QR-kóði
+ Endurstilla dulritunarlykla
+ Treyst
+ Setur
+ Aðvörun
+ Sannreynt
+ Sannreyna
+ óstöðug
+ stöðug
+ Sjálfgefin útgáfa
+ Útgáfa á þjóni
+ Heiti þjóns
+ Virkja dulritun
+ Virkja dulritun\?
+ Tímalína
+ Hætta að hunsa
+ Notendur
+ Boðsgestir
+ Sérsniðið
+ Umsjónarmenn
+ Fara út
+ Fara af spjallrás
+ Innsendingar
+ Tilkynningar
+ Stillingar
+ Meira
+ Kanna nánar
+ Öryggi
+ Bíð…
+ Könnun
+ Skrá
+ Tal
+ Hljóð
+ Mynd.
+ Myndskeið.
+ Virkja dulritun
+ Núverandi seta
+ Stillingar
+ Ítarlegar stillingar
+ Lýsingin er of stutt
+ Hreinsa gögn
+ Lykilorð
+ Skrá inn
+ Skrá inn
+ Matrix-auðkenni
+ Aðvörun
+ Næsta
+ Lykilorð
+ Notandanafn
+ Notandanafn eða tölvupóstfang
+ Næsta
+ Settu inn kóða
+ Staðfestu símanúmer
+ Næsta
+ Símanúmer (valfrjálst)
+ Símanúmer
+ Næsta
+ Tölvupóstfang (valfrjálst)
+ Tölvupóstur
+ Könnuninni er lokið
+ Valkostur %1$d
+ Spurning eða viðfangsefni könnunar
+ Aðvörun
+ Lykilorðið þitt hefur verið endurstillt.
+ Tókst!
+ Ég hef staðfest tölvupóstfangið mitt
+ Halda áfram
+ Aðvörun!
+ Tölvupóstur
+ Næsta
+ Vistfang
+ Hreinsa vinnsluferil
+ Skrá inn
+ Nýskrá
+ Halda áfram
+ einfaldri innskráningu (single sign-on)
+ Skrá inn með %s
+ Halda áfram með %s
+ Eða
+ Annað
+ Kanna nánar
+ Ég er nú þegar með notandaaðgang
+ Stofna aðgang
+ Komast í gang
+ Tengjast þjóni
+ Samfélög
+ Ólesin skilaboð
+ Fjarlægja úr eftirlætum
+ Bæta í eftirlæti
+ Stillingar
+ Þagga niður
+ Aðeins minnst á
+ Öll skilaboð
+ Öll skilaboð (hávært)
+ Tilkynnt sem óviðeigandi
+ Tilkynnt sem ruslpóstur
+ Efni tilkynnt
+ KÆRA
+ Ástæður fyrir kæru á þessu efni
+ Kæra þetta efni
+ Þetta er óviðeigandi
+ Þetta er ruslpóstur
+ %1$s kl. %2$s
+ SKRÁR
+ MYNDEFNI
+ %1$d af %2$d
+ Staðsetning
+ Könnun
+ Myndasafn
+ Myndavél
+ Tengiliður
+ Skrá
+ Opna leiðsagnarsleðann
+ Kóði
+ Auðkennisþjónn
+ Þjónustuskilmálar
+ Skoða breytingaskrá
+ Tillögur
+ QR-kóði
+ Tengill afritaður á klippispjald
+ (breytt)
+ Bíð…
+ Bein skilaboð
+ Gefðu umsögn
+ Umsagnir
+ Kerfisstillingar
+ Útgáfur
+ Hjálp og aðstoð
+ Hjálp
+ Snið:
+ Slóð:
+ Öryggi og gagnaleynd
+ Kjörstillingar
+ Almennt
+ Opinbert
+ Umfjöllunarefni
+ Heiti
+ Nafn spjallrásar
+ ÚTBÚA
+ Bein skilaboð
+ Spjallrásir
+ Bíddu aðeins…
+ Búa til nýja spjallrás
+ Skilaboðum eytt
+ Spjallrásir
+ Samtöl
+ Reyna aftur
+ Svara
+ Breyta
+ Óþekkt villa
+ Náði því
+ Sannreynt!
+ Undirritun
+ Reiknirit
+ Útgáfa
+ Endurheimta úr öryggisafriti
+ Ertu viss\?
+ Óvænt villa
+ Stöðva
+ Skipta út
+ Vista sem skrá
+ Deila
+ Lokið
+ Tókst !
+ (Ítarlegt)
+ %d+
+ %1$s: %2$s
+ fella saman
+ fletta út
+ Settu inn lykilorðið þitt.
+ Settu inn notandanafn.
+ Þögult
+ Breytir birtu gælunafni þínu
+ Fara af spjallrás
+ Birtir aðgerð
+ Hunsa
+ Deila
+ Lesa DRM-varið myndefni
+ Leyfa
+ Auðkenni spjallrásar
+ Opna í vafra
+ Endurlesa viðmótshluta
+ Viðmótshluti
+ Virkir viðmótshlutar
+ SKOÐA
+ %1$s: %2$s
+ Ég
+ Ný skilaboð
+ Spjallrás
+ Nýr atburður
+ %1$s og %2$s
+
+ - %d tilkynning
+ - %d tilkynningar
+
+ Bæta við nýjum þjóni
+ Opinbert
+ Einka
+ Staðvær vistföng
+ Gefa út
+ Aðgangur að spjallrás
+ Stillingar notandaaðgangs
+ Veldu
+ Veldu
+ Myndefni
+ Lykilorð
+ hér
+ Forskoða myndefni fyrir sendingu
+
+ - %d sekúnda
+ - %d sekúndur
+
+ Skilaboð frá vélmennum
+ Boð á spjallrás
+ Stikkorð
+ \@spjallrás
+ Hópskilaboð
+ Bein skilaboð
+ Notandanafnið mitt
+ Birtingarnafn mitt
+ Virkja í ræsingu
+ Bæta við notandaaðgangi
+ Sérsniðnar stillingar.
+ Virkja
+ Setustillingar.
+ Virkja
+ Stillingar notandaaðgangs.
+ Opna stillingar
+ Kerfisstillingar.
+ Annað
+ Sjálfgefnar tilkynningar
+ Ítarlegar stillingar á tilkynningum
+ Fjarlægja %s\?
+ Símanúmer
+ Tölvupóstföng
+ Ekkert
+ Sía
+ Spjallþræðir
+ Spjallþráður
+
+ - %d valið
+ - %d valið
+
+ Breyta stillingum
+ Bjóða notendum
+ Heimildir
+ %1$s og %2$s
+ Taka notanda úr banni
+ Banna notanda
+ Fjarlægja notanda
+ Lækka niður um stig
+ Ekkert svar
+ Bíða
+ Halda áfram
+ Símtöl
+ Alltaf spyrja
+ Aftan
+ Fram
+ Heyrnartól
+ Hátalari
+ Sími
+ Svæði
+ Skrá yfir spjallrásir
+ Ekki fleiri niðurstöður
+ Tilkynningar
+ Nýtt gildi
+ Tókst
+ Villa
+ Endurstilla
+ Hafna
+ Spila
+ Aftengjast
+ Afturkalla
+ Sækja
+ Hafna
+ Hunsa
+ Sleppa
+ Samþykkja
+ Breyta
+ Samþykki
+ Ekki núna
+ Virkja
+ Skipta um
+ Bæta við
+ Ýttu til að breyta svæðum
+ Veldu svæði
+ Hætta að birta þetta vistfang
+ Birta þetta vistfang
+ Bæta við staðværu vistfangi
+ Þessi spjallrás er ekki með nein staðvær vistföng
+ Stilltu vistföng fyrir þessa spjallrás svo notendur geti fundið hana í gegnum heimaþjóninn þinn (%1$s)
+ Nýtt birt vistfangs (t.d. #samnefni:netþjónn)
+ Engin önnur birt vistföng ennþá.
+ Engin önnur birt vistföng ennþá, bættu einu við hér fyrir neðan.
+ Eyða vistfanginu \"%1$s\"\?
+ Hætta að birta vistfangið \"%1$s\"\?
+ Birta nýtt vistfang handvirkt
+ Önnur birt vistföng:
+ Þetta er aðalvistfangið
+ Birt vistföng getur hvaða einstaklingur eða netþjónn sem er notað til að taka þátt í spjallrásinni þinni. Til að birta vistfang, þarf fyrst að stilla það sem staðvært vistfang.
+ Birt vistföng
+ Sjá og sýsla með vistföng þessa svæðis.
+ Vistföng svæða
+ Sjá og sýsla með vistföng þessarar spjallrásar og sýnileika hennar í spjallrásaskránni.
+ Vistföng spjallrása
+ Leyfa gestum að taka þátt
+ Aðgangur að svæði
+ Hver hefur aðgang\?
+ Láta mig vita fyrir
+ Uppgötvun
+ Uppfærslur spjallrásar
+ Skilaboð sem innihalda @spjallrás
+ Þegar spjallrásir eru uppfærðar
+ Heimildir svæðis
+ Ástæða fyrir banni
+ Ástæða fjarlægingar
+ Hætta við boð
+ Hætta að hunsa notanda
+ Hunsa notanda
+ Lækka þig sjálfa/n í tign\?
+ Fjarlægja úr spjalli
+ Hætta við boð
+ Notandinn sem þú hringdir í er upptekinn.
+ Þetta er ekki gilt vistfang á Matrix-þjóni
+ Tillögur að spjallrásum
+ Skoða á spjallrás
+ Þú breyttir vistföngum fyrir þessa spjallrás.
+ %1$s breytti vistföngum fyrir þessa spjallrás.
+ Þú breytti aðal- og varavistföngunum fyrir þessa spjallrás.
+ %1$s breytti aðal- og varavistföngunum fyrir þessa spjallrás.
+ Þú breyttir varavistfanginu fyrir þessa spjallrás.
+ %1$s breytti varavistfanginu fyrir þessa spjallrás.
+ Þú fjarlægðir aðalvistfang spjallrásarinnar.
+ %1$s fjarlægði aðalvistfang spjallrásarinnar.
+ %1$s stillti aðalvistfang spjallrásarinnar sem %2$s.
+ Þú bættir við %1$s og fjarlægðir %2$s sem vistföng fyrir þessa spjallrás.
+ %1$s bætti við %2$s og fjarlægði %3$s sem vistföng fyrir þessa spjallrás.
+
+ - Þú fjarlægðir %1$s sem vistfang fyrir þessa spjallrás.
+ - Þú fjarlægðir %1$s sem vistföng fyrir þessa spjallrás.
+
+
+ - %1$s fjarlægði %2$s sem vistfang fyrir þessa spjallrás.
+ - %1$s fjarlægði %2$s sem vistföng fyrir þessa spjallrás.
+
+
+ - Þú bættir við %1$s sem vistfangi fyrir þessa spjallrás.
+ - Þú bættir við %1$s sem vistföngum fyrir þessa spjallrás.
+
+
+ - %1$s bætti við %2$s sem vistfangi fyrir þessa spjallrás.
+ - %1$s bætti við %2$s sem vistföngum fyrir þessa spjallrás.
+
+ Breyta umfjöllunarefni
+ Uppfæra svæðið
+ Uppfæra spjallrásina
+ Senda m.room.server_acl atburði
+ Breyta heimildum
+ Breyta nafni svæðis
+ Breyta nafni spjallrásar
+ Breyta sýnileika ferils
+ Virkja dulritun svæðis
+ Virkja dulritun spjallrásar
+ Skipta um aðalvistfang svæðisins
+ Skipta um aðalvistfang spjallrásarinnar
+ Skipta um táknmynd svæðis
+ Skipta um auðkennismynd spjallrásar
+ Breyta viðmótshlutum
+ Tilkynna öllum
+ Fjarlægja skilaboð send af öðrum
+ Banna notendur
+ Fjarlægja notendur
+ Senda skilaboð
+ Sjálfgefið hlutverk
+ %1$s, %2$s og aðrir
+ Hringing…
+ Afrita
+ Merkja sem lesið
+ Ertu viss um að þú viljir skrá þig út\?
+ Leggja á
+ Hafna
+ Samþykkja
+ Lokið
+ Svæði
+ Hefja spjall
+ Ekkert
+ Ertu viss\?
+ Öryggisafrit af lykli
+ Sjálfgefið í kerfinu
+ Sendi skilaboð…
+ Skilaboð send
+ Tóm spjallrás (var %s)
+
+ - %1$s, %2$s, %3$s og %4$d til viðbótar
+ - %1$s, %2$s, %3$s og %4$d til viðbótar
+
+ %1$s, %2$s og %3$s
+ Sérsniðið
+ Sérsniðið (%1$d)
+ Sjálfgefið
+ Umsjónarmaður
+ %1$s bauð %2$s
+ Engin breyting.
+ %1$s gekk í hópinn
+ Boðið þitt
\ No newline at end of file
diff --git a/vector/src/main/res/values-iw/strings.xml b/vector/src/main/res/values-iw/strings.xml
index 73a57823eb..b70034888f 100644
--- a/vector/src/main/res/values-iw/strings.xml
+++ b/vector/src/main/res/values-iw/strings.xml
@@ -203,7 +203,7 @@
יעדי התראות
ניהול מפתחות קריפטוגרפיה
קריפטוגרפיה
- השתמש במנהל שילוב לניהול בוטים, גשרים, ווידג\'טים וחבילות מדבקות.
+ השתמש במנהל אינטגרציה לניהול בוטים, גשרים, ווידג\'טים וחבילות מדבקות.
\nמנהלי אינטגרציה מקבלים נתוני תצורה ויכולים לשנות ווידג\'טים, לשלוח הזמנות לחדר ולהגדיר רמות כוח מטעמכם.
אינטגרציות
מתקדם
@@ -440,7 +440,7 @@
חסום הכל
אפשר
יישומון זה רוצה להשתמש במשאבים הבאים:
- מצטערים, שיחות ועידה עם Jitsi אינן נתמכות במכשירים ישנים (מכשירים עם מערכת הפעלה אנדרואיד מתחת ל -5.0)
+ מצטערים, שיחות ועידה עם Jitsi אינן נתמכות במכשירים ישנים (מכשירים עם מערכת הפעלה אנדרואיד מתחת ל -6.0)
מזהה חדר
מזהה ישומון
ערכת הנושא שלכם
@@ -515,7 +515,7 @@
כל החדרים המקומיים %s
כל החדרים בשרת %s
- כתובת אתר של שרת בית
+ שם השרת
בחר מדריך חדרים
אם הם לא תואמים, אבטחת התקשורת שלך עלולה להיפגע.
אשרו על ידי השוואה בין הדברים הבאים להגדרות המשתמש בפגישה האחרת שלכם:
@@ -686,7 +686,7 @@
בועט משתמש עם מזהה נתון
הגדר את נושא החדר
עזוב חדר
- מצטרף לחדר עם כינוי נתון
+ מצטרף לחדר עם כתובת ידועה
מזמין משתמש עם זיהוי נתון לחדר הנוכחי
משתמש מבוטל עם מזהה נתון
הגדר את רמת ההרשאה של המשתמש
@@ -705,7 +705,7 @@
רטוט בעת אזכור משתמש
כולל שינויים באווטאר ושמות תצוגה.
הצג אירועי חשבון
- הזמנות, בעיטות ואיסורים אינם מושפעים.
+ הזמנות, הסרות ואיסורים אינם מושפעים.
הראה אירועי הצטרפות ועזיבה
לחץ על אישורי הקריאה לרשימה מפורטת.
הצג קבלות הצג קבלות קריאה
@@ -2099,8 +2099,8 @@
- שיחה פעילה ·
- %1$d שיחות פעילות ·
-
-
+
+
שיחה פעילה (%1$s)
אירעה שגיאה בחיפוש מספר הטלפון
@@ -2124,17 +2124,17 @@
להודיע לכל החדר
- %1$d יותר
-
-
-
+
+
+
%1$s, %2$s ואחרים
%1$s ו %2$s
- %d שינוי ברשימות ACL בשרתים
-
-
-
+
+
+
נהל חדרים
החלט מי יכול לראות ולהצטרף לחדר זה.
@@ -2175,4 +2175,246 @@
שם המשתמש שלי
הזמנות לחדר
מילות מפתח
+ הזמן אל %s
+ הזמן אנשים
+ הזמן אנשים למרחב שלך
+ תֵאוּר
+ יוצר מרחב…
+ אַקרַאִי
+ ראשי
+ בואו ניצור חדר לכל אחד מהם. אתה יכול להוסיף עוד מאוחר יותר, כולל אלה שכבר קיימים.
+ על איזה דברים אתה עובד\?
+ ודא שלאנשים הנכונים יש גישה לחברה %s. תוכל להזמין עוד מאוחר יותר.
+ מי הם חבריך לצוות\?
+ אנחנו ניצור עבורם חדרים. אתה יכול להוסיף עוד מאוחר יותר גם.
+ איזה דיונים אתה רוצה לקיים ב-%s\?
+ תן לזה שם כדי להמשיך.
+ הוסף כמה פרטים כדי לשפר לזיהוי. אתה יכול לשנות את זה בכל שלב.
+ הוסף כמה פרטים כדי לבלוט. אתה יכול לשנות את זה בכל שלב.
+ צור מרחב
+ הזמן רק, הכי טוב לעצמך או לצוותים שלך
+ פְּרָטִי
+ פתוח לכל אחד, הכי מתאים לקהילות
+ צִבּוּרִי
+ מרחב פרטי עבורך ועבור חברי הצוות שלך
+ אני וחברי הצוות
+ מרחב פרטי לארגון החדרים שלך
+ רק אני
+ ודא שלאנשים הנכונים יש גישה אל %s.
+ עם מי אתה עובד\?
+ כדי להצטרף למרחב קיים, אתה צריך הזמנה.
+ אתה יכול לשנות את זה מאוחר יותר
+ איזה סוג מרחב אתה רוצה ליצור\?
+ המרחב הפרטי שלך
+ המרחב הציבורי שלך
+ הוסף מרחב
+ מרחב אישי
+ מרחב ציבורי
+ האם אתה בטוח שברצונך למחוק את כל ההודעות שלא נשלחו בחדר הזה\?
+ מחק הודעות שלא נשלחו
+ שליחת ההודעות נכשלה
+ האם ברצונך לבטל את שליחת ההודעה\?
+ מחק את כל ההודעות שנכשלו
+ נִכשָׁל
+ נשלח
+ שְׁלִיחָה
+ משדרג חדר לגרסה חדשה
+ עזוב את החדר עם מזהה נתון (או החדר הנוכחי אם ריק)
+ הצטרף למרחב עם המזהה הנתון
+ הוסף למרחב הנתון
+ צור מרחב
+ תוכן האירוע
+ מצב האירוע נשלח!
+ האירוע נשלח!
+ אירוע שגוי
+ חסר סוג הודעה
+ אין תוכן
+ תוכן האירוע
+ מצב מפתח
+ סוג
+ שלח אירוע מצב מותאם אישית
+ ערוך תוכן
+ מצב אירוע
+ שלח מצב אירוע
+ שלח אירוע מותאם אישית
+ חקור מצב חדר
+ כלי מפתח
+ לא זמין
+ לא על הקו
+ על הקו
+ חדר ציבורי
+ צפה באישורי קריאה
+ אל תתריעה
+ מתריעה ללא קול
+ מתריעה עם צליל
+ ההודעה לא נשלחה עקב שגיאה
+ לא בָּדוּק
+ בָּדוּק
+ הזמינו בדואר אלקטרוני
+ זה רק אתה כרגע. %s יהיה טוב יותר עם אחרים.
+ בחלל זה אין חדרים
+ אנא צור קשר עם מנהל השרת הביתי שלך לקבלת מידע נוסף
+ נראה שהשרת הביתי שלך עדיין לא תומך במרחבים
+ מרגיש ניסיוני\?
+\nניתן להוסיף חללים קיימים למרחב.
+ הצג את כל החדרים בדף הבית
+ כל החדרים שבהם אתה נמצא יוצגו בדף הבית.
+ ניהול חדרים ומרחבים
+ סמן כלא מוצע
+ מוצע
+ סמן כמוצע
+ מחפש מישהו שאינו ב-%s\?
+ %s מזמין אותך
+ הערה: האפליקציה תופעל מחדש
+ אפשר הודעות שרשור
+ המערכת שלך תשלח אוטומטית יומנים כאשר מתרחשת שגיאה ללא יכולת לפענח
+ דווח אוטומטית על שגיאות פענוח.
+ אתם מוזמנים
+ מרחבים הם דרך חדשה לאיחוד של חדרים ואנשים.
+ הוסף מרחב לכל מרחב שאתה מנהל.
+ הוסף מרחבים קיימים
+ הוסף חדרים קיימים
+ הוסף חדרים ומרחבים קיימים
+ בחר דברים להשאיר
+ השאירו חדרים ומרחבים ספציפיים…
+ אל תעזובו חדרים וא מרחבים
+ עזבו את כל החדרים והמרחבים
+ אתה המנהל היחיד של המרחב הזה. עזיבה תגרום לכך שאף אחד לא ישלוט על המרחב.
+ לא תוכל להצטרף מחדש אלא אם כן תוזמן מחדש.
+ אתה האדם היחיד כאן. אם תעזוב, אף אחד לא יוכל להצטרף בעתיד, כולל אותך.
+ האם אתה בטוח שברצונך לעזוב את %s\?
+ עזוב את המרחב
+ הוסיפו חדרים
+ גלה חדרים
+
+ - אדם %d שאתה מכיר כבר הצטרף
+ - %d אנשים שאתה מכיר כבר הצטרפו
+ - %d אנשים שאתה מכיר כבר הצטרפו
+ - %d אנשים שאתה מכיר כבר הצטרפו
+
+ גילוי (%s)
+ סיים את ההגדרה
+ הזמן בדואר אלקטרוני, מצא אנשי קשר ועוד…
+ סיים להגדיר את הגילוי.
+ אינך משתמש כעת בשרת זהות. על מנת להזמין חברים לצוות ולהיות ניתנים לגילוי על ידם, הגדר שרת זהות למטה.
+ כינוי זה אינו נגיש בשלב זה.
+\nנסה שוב מאוחר יותר, או בקש ממנהל חדר לבדוק אם יש לך גישה.
+ הם לא יהיו חלק מ-%s
+ רק לחדר הזה
+ הם יוכלו לחקור את %s
+ הזמן אל %s
+ שתף קישור
+ הזמן לפי שם משתמש או דואר אלקטרוני
+ ההצפנה הוגדרה בצורה שגויה.
+ אמת על ידי השוואת אימוג\'י
+ סרוק עם המכשיר הקיים
+ סרוק את הקוד עם המכשיר האחר או החלף וסרוק את הקוד מהמכשיר הקיים
+ קול
+ יצירת מרחב…
+ כתובת מרחב
+ קידומת ( ͡° ͜ʖ ͡°) להודעת טקסט רגילה
+ הראה קצת מידע שימושי לעזור לטיפול בבאגים ביישום
+ הראה מידע על באגים במסך
+ נראה שכתובת הדואר האלקטרוני אינה תקפה
+ התחבר לשרת
+ מחפש להצטרף לשרת קיים \?
+ עדיין לא בטוח \? אתה יכול %s
+ קהילות
+ צוותים
+ חברים ומשפחה
+ נעזור לך להיות מחובר.
+ עם מי תדברו הכי הרבה\?
+ הצפנה קצה לקצה ולא נדרש מספר טלפון. אין פרסומות או כריית מידע.
+ בחירת מקום שמירת השיחה שלך , נותנת לך בקרה ועצמאות . מחובר דרך מטריקס.
+ תקשורת מאובטחת ועצמאית המעניקה לך את אותה רמת פרטיות כמו שיחה פנים אל פנים בבית שלך.
+ הודעות לקבוצתך.
+ אתה בבקרה.
+ שיחות בבעלותך.
+ הגדרות חדר
+ סקר
+ הקובץ גדול מדי להעלאה.
+ האם אתה מאשר את שליחת המידע \?
+ שליחת דואר אלקטרוני ומספרי טלפון אל %s
+ רשימת אנשי הקשר שלך הינה פרטית. לחשיפת משתמשים מרשימת אנשי הקשר שלך , נדרש אישורך לשליחת מידע על איש הקשר לשרת ההזדהות.
+ לא סופקה מדיניות על ידי שרת הזדהות
+ הסתר מדיניות שרת הזדהות
+ הראה מדיניות שרת הזדהות
+ פתח הגדרות
+ משוב על מרחבים
+ הגדרות מערכת
+ גרסאות
+ קבלת עזרה בשימוש ${app_name}
+ עזרה ותמיכה
+ עזרה
+ משפטים
+ אתה צופה בת\'רד זה!
+ יצירת מרחב חדש
+ צפייה בחדר
+ מענה בת\'רד
+ הצגת מידע על המשתמש
+ משנה את האווטר שלך בחדר הנוכחי בלבד
+ משנה את האווטר בחדר הנוכחי
+ משנה את הכינוי בחדר הנוכחי בלבד
+ הפקודה \"%s\" מזוהה אך אינה נתמכת.
+ עזרו לנו לזהות ולשפר את ${app_name} על ידי שיתוף השימוש בצורה אנונימי. להבנת השימוש של אנשים במכשירים שונים , אנחנו נחולל מזהה אקראי , אשר ישותף על ידי המכשירים שלך .
+\n
+\n ניתן לקרוא את כל התנאים %s.
+
+ - התוצאה הסופית מתבססת על הצבעה %1$d
+
+ - התוצאה הסופית מתבססת על הצבעות %1$d
+
+
+ בחר אילו מרחבים יכולים לגשת לחדר זה. בבחירת מרחב החברים יוכלו למצוא ולהצטרף לחדר.
+ חברים במרחב %s יכולים למצוא , לצפות ולהצטרף.
+ כל אחד במרחב בחדר זה יכול להצטרף. רק מנהלי מערכת של החדר יכולים להוסיף למרחב.
+ אנחנו לא משתפים מידע עם חברות צד שלישי
+ אנחנו לא מקליטים או עושים פרופיל מנתוני החשבון
+ הפסק להתעלם ממשתמש , אשר הודעותיו נראות
+ הגדר את שם החדר
+ התעלם ממשתמש , אשר מעלים הודעות ממך
+ האם מאושרת עזיבת שיחת הועידה הנוכחית ומעבר לשיחת ועידה אחרת \?
+ מצטערים , אירעה שגיאה בניסיון הצטרפות לשיחת הועידה
+ השרת כבר נמצא ברשימה
+ לא ניתן למצוא את השרת או את רשימת החדשים
+ הכנס את שם השרת החדש שיחשף
+ הוספת שרת חדש
+ השרת שלך
+ גרסת חדר
+ מרחבים או חדרים שאינכם מכירים
+ מרחב שמכיל את החדר
+ בחרו מרחבים
+ מרחבים פתוחים
+ אפשרו לחברים במרחב להצטרף.
+ מרחב לחברים בלבד
+ כל אחד יכול למצוא את המרחב ולהצטרף
+ כל אחד יכול להצטרף
+ ציבורי
+ רק מוזמנים יכולים להצטרף
+ פרטי (מוזמן בלבד)
+ פרטי
+ הגדרת גישה לא ידועה(%s)
+ כל אחד יכול לבקש להצטרף לחדר , החברים יוכלו לאשר או לדחות זאת
+ סדר כתובות במרחב זה.
+ מרחב כתובות
+ אפשר לאורחים להצטרף
+ מרחב גישה
+ למי יש גישה\?
+ הגדרות חשבון
+ ניתן לסדר את ההתראות ב-%1$s.
+ שימו לב שהתראות על מילים מוזכרות לא זמינות בחדרים מוצפנים בטלפון הנייד
+ הודע לי עבור
+ אין מדיניות לשרת זה
+ ספריות חברות צד שלישי
+ מדיניות שרת ההזדהות שלך
+ מדיניות השרת הביתי שלך
+ מדיניות ${app_name}
+ ניתן לסגור בכל זמן בהגדרות
+ כאן
+ עזרה בשיפור ${app_name}
+ לא תתקבלנה התראות על מילים המוזכרות בחדרים מוצפנים בטלפון הנייד.
+ הודעות על ידי בוט
+ חדר@
+ הודעות קבוצתיות
+ הודעות ישירות מוצפנות
\ No newline at end of file
diff --git a/vector/src/main/res/values-pt-rBR/strings.xml b/vector/src/main/res/values-pt-rBR/strings.xml
index cd237407d2..f2eaf8d993 100644
--- a/vector/src/main/res/values-pt-rBR/strings.xml
+++ b/vector/src/main/res/values-pt-rBR/strings.xml
@@ -1414,7 +1414,7 @@
Quase lá! %s está mostrando um tick (✓)\?
Sim
Não
- A conexão com o servidor foi perdida
+ Conectividade ao servidor tem sido perdida
Modo avião está ligado
Ferramentas Dev
Dados de Conta
diff --git a/vector/src/main/res/values-ru/strings.xml b/vector/src/main/res/values-ru/strings.xml
index 465bbbb28c..9a925ecaa7 100644
--- a/vector/src/main/res/values-ru/strings.xml
+++ b/vector/src/main/res/values-ru/strings.xml
@@ -39,7 +39,6 @@
Приглашение в комнату
%1$s и %2$s
Пустая комната
-
Начальная синхронизация:
\nИмпорт учетной записи…
Начальная синхронизация:
@@ -258,7 +257,6 @@
Удалить
Переименовать
Пожаловаться на содержимое
-
или
Приглашение
Выйти из учётной записи
@@ -281,7 +279,6 @@
Только Matrix контакты
Нет результатов
Комнаты
-
Отправить логи
Отправить журналы ошибок
Отправить снимок экрана
@@ -309,11 +306,9 @@
Это не похоже на действительный адрес электронной почты
Этот адрес электронной почты уже используется.
Забыли пароль?
-
Этот домашний сервер хочет убедиться, что вы не робот
Должен быть введен адрес электронной почты привязанный к учетной записи.
Не удалось проверить адрес электронной почты: убедитесь, что вы перешли по ссылке из сообщения
-
Пожалуйста, введите корректный URL
Неверный формат JSON
Не содержит допустимого JSON
@@ -329,12 +324,10 @@
Идёт разговор…
Вызываемый абонент не смог ответить.
Информация
-
${app_name} необходимы разрешения на доступ к микрофону, чтобы выполнять звонки.
${app_name} необходимы разрешения на доступ к камере и микрофону для видеовызовов.
\n
\nПожалуйста дайте разрешение в следующем окне для звонка.
-
ДА
НЕТ
Продолжить
@@ -342,7 +335,6 @@
Присоединиться
Отклонить
Перейти к непрочитанному
-
Покинуть комнату
Вы уверены, что хотите выйти из комнаты\?
ПРЯМЫЕ СООБЩЕНИЯ
@@ -369,7 +361,6 @@
Сертификат был изменен с того, которому доверял ваш телефон. Это ОЧЕНЬ НЕОБЫЧНО. Рекомендуется НЕ ПРИНИМАТЬ этот новый сертификат.
Сертификат изменился с ранее доверенного на недействительный. Возможно, сервер обновил свой сертификат. Свяжитесь с администратором сервера для получения ожидаемого отпечатка сертификата.
Примите сертификат только если администратор сервера опубликовал отпечаток сертификата, который соответствует указанному выше.
-
Поиск
Фильтр списка пользователей
Нет результатов
@@ -414,7 +405,6 @@
Обновить публичное имя
Недавно
%1$s @ %2$s
-
Аутентификация
Авторизован как
Домашний сервер
@@ -445,7 +435,6 @@
Это экспериментальные функции, которые могут повести себя неожиданным образом. Используйте с осторожностью.
Установить как основной адрес
Сбросить основной адрес
-
Ошибка дешифровки
Публичное имя
ID сессии
@@ -456,7 +445,6 @@
Экспорт
Введите парольную фразу
Подтвердите парольную фразу
-
Импорт E2E ключей комнаты
Импорт ключей комнаты
Импортировать ключи из локального файла
@@ -468,7 +456,6 @@
Подтвердить
Чтобы убедиться, что этой сессии можно доверять, обратитесь к ее владельцу, используя другие способы (например, лично или по телефону), и спросите, соответствует ли ключ, который он видит в настройках для этой сессии:
Если они не совпадают, безопасность вашего общения может быть поставлена под угрозу.
-
Выбор каталога комнат
Имя сервера
Все комнаты на сервере %s
@@ -545,7 +532,6 @@
Причина: %1$s
Встряхните устройство, чтобы сообщить об ошибке
Список участников
-
- %d комната
- %d комнаты
@@ -560,7 +546,6 @@
Аватар
-
- %d участник
- %d участника
@@ -573,7 +558,6 @@
- %d новых сообщений
-
- %d изменение членства
- %d изменения членства
@@ -586,7 +570,6 @@
- %d непрочитанных уведомлений
-
Чутье
Отправить стикер
Отправить стикер
@@ -633,18 +616,12 @@
Нажмите здесь для просмотра старых сообщений
Присоединиться к комнате с указанным адресом
Для исправления управления приложениями Matrix
-
-
-
-
- %d выбран
- %d выбрано
- %d выбраны
-
-
Системные оповещения
Ошибка
Создать парольную фразу
@@ -733,7 +710,6 @@
Показывать события о вступлении/выходе
Показывать события аккаунта
Включает изменения аватара и отображаемого имени.
-
Использовать системную камеру вместо камеры Element.
%1$s: %2$s
%d+
@@ -814,7 +790,6 @@
Невозможно расшифровать резервную копию с помощью этого ключа восстановления: убедитесь, что вы ввели правильный ключ.
Невозможно расшифровать резервную копию с помощью этого пароля: убедитесь, что вы ввели правильный пароль.
Генерация ключей восстановления с использованием парольной фразы может занять несколько секунд.
-
[%1$s]
\nЭта ошибка вне контроля ${app_name}. На телефоне нет учетной записи Google. Пожалуйста, добавьте аккаунт Google.
[%1$s]
@@ -855,7 +830,6 @@
Использовать резервное копирование ключей
Управление резервным копированием ключей
Новые ключи зашифрованных сообщений
-
Ваши ключи копируются.
(Дополнительно) Настройка с ключом восстановления
Или защитите резервную копию с помощью ключа восстановления, сохранив его в безопасном месте.
@@ -910,11 +884,8 @@
Поделиться
Запрос поделится ключом
Игнорировать
-
Проверено!
Понял
-
-
Запрос на подтверждение
%s желает подтвердить вашу сессию
Неизвестная ошибка
@@ -1011,7 +982,6 @@
Никто
Отмена
Отключить
-
Не удается связаться с домашним сервером по этому URL, пожалуйста, проверьте его
Оптимизирован для батареи
Оптимизирован для работы в реальном времени
@@ -1023,7 +993,6 @@
${app_name} будет синхронизироваться в фоновом режиме периодически в точное время (настраивается).
\nЭто повлияет на использование радио и батареи, появится постоянное уведомление о том, что ${app_name} прислушивается к событиям.
Вы не будете уведомлены о входящих сообщениях, когда приложение находится в фоновом режиме.
-
Изменить настройки обнаружения.
Вы не используете какой-либо сервер обнаружения
Похоже, вы пытаетесь подключиться к другому домашнему серверу. Вы хотите выйти\?
@@ -1090,7 +1059,7 @@
Покинуть комнату
%1$s сделал(а) комнату доступной для всех, у кого есть ссылка.
%1$s сделал(а) комнату доступной только по приглашению.
- Подробные логи помогут разработчикам, предоставив больше информации, когда вы отправляете ВзмахЯрости. Даже когда они разрешены, приложение не записывает ваши сообщения и другие приватные данные.
+ Подробные логи помогут разработчикам, предоставив больше информации, когда вы отправляете \"Яростное встряхивание\". Даже когда они разрешены, приложение не записывает ваши сообщения и другие приватные данные.
Закройте меню создание комнаты…
Вниз
Контакт
@@ -1276,7 +1245,6 @@
Лента сообщений
Ключ сообщения
Распечатайте его и храните в безопасном месте
-
Шифрование включено
Шифрование не включено
%1$s: %2$s
@@ -1311,7 +1279,6 @@
Закрыть окно резервного копирования ключей
%s прочитано
Не удалось обработать данные
-
Воспроизвести
Копировать
Удачно
@@ -1405,10 +1372,10 @@
Это недопустимый идентификатор пользователя. Ожидаемый формат: \'@user:homeserver.org\'
Не удалось найти действительный домашний сервер. Пожалуйста, проверьте свой идентификатор
Начальная синхронизация…
- СотрясениеЯрости
+ Яростное встряхивание
Порог обнаружения
Встряхните телефон, чтобы проверить порог обнаружения
- Обнаружено потрясение!
+ Обнаружено встряхивание!
Показываем только первые результаты, наберите больше букв…
Раннее падение
${app_name} может падать чаще, когда происходит непредвиденная ошибка
@@ -1443,7 +1410,6 @@
Подтверждено %s
Подтверждённых %s
Ожидаем %s…
-
Сообщения в этой комнате не защищены сквозным шифрованием.
Сообщения в этой комнате защищены сквозным шифрованием.
\n
@@ -1871,7 +1837,6 @@
Скрыть дополнительные настройки
Показать дополнительные настройки
%1$d из %2$d
-
Дать согласие
Отозвать моё согласие
Больше никаких результатов
@@ -1973,8 +1938,6 @@
Перевод
Подключиться
Сначала посоветуйтесь
-
-
Нет учётных данных, неправильная учётная запись пользователя и/или пароль
Вы уверены, что хотите удалить все неотправленные сообщения в этой комнате\?
Удалить неотправленные сообщения
@@ -2061,7 +2024,6 @@
Обновление
Пожалуйста, будьте терпеливы, это может занять некоторое время.
Присоединиться к замещенной комнате
-
Безымянная Комната
Некоторые комнаты могут быть скрыты, потому что они приватные, и вам нужно приглашение.
Некоторые комнаты могут быть скрыты, потому что они приватные, и вам нужно приглашение.
@@ -2130,7 +2092,7 @@
Приватное пространство для организации ваших комнат
Я и члены команды
Только я
- Убедитесь, что нужные люди имеют доступ к %s. Вы сможете изменить это позже.
+ Убедитесь, что нужные люди имеют доступ к %s.
С кем вы работаете\?
Чтобы присоединиться к существующему пространству, вам необходимо получить приглашение.
Вы сможете изменить это позже
@@ -2453,7 +2415,6 @@
Местоположение
Вы согласны отправить эту информацию\?
Чтобы обнаружить существующие контакты, необходимо отправить контактную информацию (электронную почту и номера телефонов) на сервер обнаружения. Мы хешируем ваши данные перед отправкой для обеспечения конфиденциальности.
-
Отправить электронные адреса и номера телефонов %s
Ваши контакты приватны. Чтобы обнаружить пользователей из ваших контактов, нам необходимо ваше разрешение на отправку контактной информации на ваш сервер обнаружения.
Системные настройки
@@ -2485,16 +2446,16 @@
Включить
Слежка за уведомлениями
Вам не разрешено подключаться к этой комнате
- Организуйте обсуждение в потоках
- Показать все потоки в которых вы участвуете
- Все Потоки
- Просмотр Потоков
+ Организуйте обсуждение с помощью веток
+ Показать все ветки, в которых вы участвуете
+ Все ветки
+ Посмотреть ветки
Посмотреть в комнате
- Показать всплывающие сообщения
+ Показывать сообщения в пузырях
Не удалось загрузить карту
Карта
Примечание: приложение будет перезапущено
- Включить Сообщения Потока
+ Включить ветки сообщений
Подключиться к серверу
Хотите присоединиться к существующему серверу\?
пропустить вопрос
@@ -2504,18 +2465,36 @@
Друзья и семья
Мы поможем вам подключится.
С кем вы будете общаться больше всего\?
- Вы уже просматриваете этот Поток!
+ Вы уже просматриваете эту ветку!
Просмотр в Комнате
- Ответить в Поток
- Команда «%s» распознается, но не поддерживается в потоках.
- Из Потока
+ Ответить в ветке
+ Команда «%s» распознается, но не поддерживается в ветках.
+ Из ветки
Совет: нажмите и удерживайте сообщение и используйте «%s».
- Потоки помогают хранить ваши разговоры по темам и легко отслеживать их.
- Мои Потоки
- Показать все потоки в текущей комнате
+ Ветки помогают хранить ваши разговоры по темам и легко отслеживать их.
+ Мои ветки
+ Показать все ветки этой комнаты
Фильтр
- Потоки
- Поток
- Фильтровать Потоки в комнате
- Скопировать ссылку в поток
+ Ветки
+ Ветка
+ Фильтровать ветки в комнате
+ Скопировать ссылку в ветку
+ Уведомления комнаты
+ Пользователи
+ Оповестить всю комнату
+
+ - И ещё %1$d
+ - И ещё %1$d
+ - И ещё %1$d
+ - И ещё %1$d
+
+ Свернуть
+ %1$s, %2$s и другие
+ %1$s и %2$s
+
+ - %d изменение ACL сервера
+ - %d изменения ACL сервера
+ - %d изменений ACL сервера
+ - %d изменений ACL сервера
+
\ No newline at end of file
diff --git a/vector/src/main/res/values-sk/strings.xml b/vector/src/main/res/values-sk/strings.xml
index 8d97ff7266..6650890ac3 100644
--- a/vector/src/main/res/values-sk/strings.xml
+++ b/vector/src/main/res/values-sk/strings.xml
@@ -2274,7 +2274,7 @@
Týmto sa zastaví možnosť hlasovania a zobrazia sa konečné výsledky ankety.
Určite chcete túto anketu odstrániť\? Po odstránení ju už nebudete môcť obnoviť.
Typ ankety
- Otvoriť anketu
+ Otvorená anketa
Hlasujúci uvidia výsledky hneď po hlasovaní
Uzavretá anketa
Zobraziť vlákna
@@ -2452,4 +2452,18 @@
- %1$d ďalších
Zadajte URL adresu servera Modular Element alebo adresu servera, ktorý si želáte použiť
+ Hlasovať
+ Odoslať stav udalosti
+ Priradenie sa nepodarilo.
+ V súčasnosti neexistuje žiadne priradenie k tomuto identifikátoru.
+ použite záložný kľúč na obnovu kľúča
+ Uloženie tajnej zálohy kľúčov v SSSS
+ Generovanie kľúča SSSS z kľúča pre obnovu
+ Definovanie predvoleného kľúča SSSS
+ Rýchle-zlyhanie
+ Najprv konzultovať
+ Vyberte si, čo opustíte
+ Opustiť miestnosť s daným id (alebo aktuálnu miestnosť, ak je prázdna)
+ Varovná úroveň dôveryhodnosti
+ Pripojiť
\ No newline at end of file
diff --git a/vector/src/main/res/values-sq/strings.xml b/vector/src/main/res/values-sq/strings.xml
index 48fc274eb7..0fc5c7f6fb 100644
--- a/vector/src/main/res/values-sq/strings.xml
+++ b/vector/src/main/res/values-sq/strings.xml
@@ -310,7 +310,7 @@
PO
Vazhdo
Hiqe
- Bëhuni pjesë
+ Hyni
Hidheni tej
Anëtarë liste
Hidhu te të palexuarit
diff --git a/vector/src/main/res/values-zh-rCN/strings.xml b/vector/src/main/res/values-zh-rCN/strings.xml
index d2e17e856d..67bf3f5e06 100644
--- a/vector/src/main/res/values-zh-rCN/strings.xml
+++ b/vector/src/main/res/values-zh-rCN/strings.xml
@@ -39,7 +39,6 @@
空聊天室
聊天室邀请
%1$s 和 %2$s
-
初始化同步:
\n正在导入账号…
初始化同步:
@@ -262,10 +261,8 @@
拍摄照片或视频
此主服务器想确认你不是机器人
电子邮箱地址验证失败:请确保你已点击邮件中的链接
-
原始
通话正在连接…
-
${app_name} 需要权限以访问你的麦克风来进行语音通话。
私聊
邀请
@@ -283,7 +280,6 @@
忽略
指纹(%s):
无法验证远程服务器的身份。
-
应用信息
启用这个账号的通知
启用这个设备的通知
@@ -301,7 +297,6 @@
通知
已忽略的用户
通讯录权限
-
身份认证
当前密码
是否重新显示所有来自 %s 的消息?
@@ -319,14 +314,12 @@
高级
此聊天室的内部 ID
这些是实验性功能,可能会出现不可预料的错误。请谨慎使用。
-
导出端对端聊天室密钥
导出聊天室密钥
导出密钥到本地文件
导出
输入密语
确认密语
-
导入端对端聊天室密钥
导入聊天室密钥
从本地文件导入密钥
@@ -338,25 +331,20 @@
永久链接
重命名
举报内容
-
问题反馈
为分析此问题,本客户端的日志将会随此问题反馈发送。本问题反馈,包括日志与截图,将不会被公开显示。若你希望仅发送上面的文字,请取消选择:
问题反馈发送成功
问题反馈发送失败(%s)
-
异常的 JSON
呼入的视频通话
呼入的语音通话
通话中…
通话未被接听。
信息
-
-
${app_name} 需要权限以访问你的摄像机和麦克风来进行视频通话。
\n
\n请在接下来的弹出窗口中授权允许访问,以便进行通话。
移除
-
你将不能撤销这个修改,因为你正在让这个用户和你拥有相同的特权级别。
\n你确定吗?
这可能意味着有人正在恶意劫持你的流量,或者你的手机不信任远程服务器提供的数字证书。
@@ -375,7 +363,6 @@
取消设置为主要地址
确认
你似乎沮丧地摇了摇手机。你想打开问题反馈界面吗?
-
证书已从一个先前受你的设备信任的证书更改为另一个。这非常反常!建议你不要接受此新证书。
证书已从曾受信任的证书更改为不受信任的证书。服务器可能已更新其证书,请联系管理员并核对服务器的指纹。
请仅在服务器管理员发布了与上述指纹匹配的指纹的情况下接受该证书。
@@ -391,7 +378,6 @@
实验室
为验证此设备是否可信,请通过其他方式(例如面对面交换或拨打电话)与其拥有者联系,并询问他们该设备的用户设置中的密钥是否与以下密钥匹配:
如果它们不匹配,你通讯的安全性可能会受到影响。
-
邀请
收藏夹
联系人
@@ -479,7 +465,6 @@
社群
摇一摇快捷反馈问题
-
- %d 位成员的状态发生了变化
@@ -490,7 +475,6 @@
- %d 条未读消息
显示成员
-
徽章
- %d 个聊天室
@@ -505,9 +489,7 @@
你已被 %2$s 从 %1$s 中封禁
理由:%1$s
头像
-
%1$s 条在 %2$s 中
-
停用账号
停用我的账号
发送统计分析数据
@@ -553,16 +535,9 @@
对话在此继续
这个聊天室是另一个对话的延续
点击此处查看更早的消息
-
-
-
-
-
- 已选择 %d 个
-
-
系统警告
联系你的服务管理员
本服务器其中一项资源已超出限制,部分用户将无法登录。
@@ -631,7 +606,6 @@
邀请、移除与封禁事件不受影响。
显示账号变动事件
包括头像与显示名称的变动。
-
密码
%d+
%1$s:%2$s
@@ -719,7 +693,6 @@
保存恢复密钥
分享
保存为文件
-
请制作一份拷贝
分享恢复密钥…
正在使用密语来生成恢复密钥,此过程可能会花费几秒钟。
@@ -765,7 +738,6 @@
正在删除备份…
删除备份
要从此服务器中删除你备份的加密密钥吗?你将无法再使用恢复密钥来读取加密的历史消息。
-
永不丢失已加密消息
使用备份密钥
新加密信息密钥
@@ -783,7 +755,6 @@
按回车发送消息
软键盘的 Enter 按钮将发送消息而不是添加换行符
密码无效
-
媒体
默认压缩
选择
@@ -865,7 +836,6 @@
撤消
断开连接
拒绝
-
這不是有效的 Matrix 服务器位置
无法在此 URL 找到主服务器,请检查
播放
@@ -918,7 +888,6 @@
\n这将影响网络和电池的使用,将显示一个永久通知表明 ${app_name} 正在监听事件。
无后台同步
应用在后台时你不会收到消息通知。
-
集成
使用集成管理器管理机器人、桥接、部件和贴纸包。
\n集成管理器接收配置数据,可以代表你修改部件、发送聊天室邀请及设置特权等级。
@@ -972,8 +941,6 @@
安全备份
保护加密信息及数据的访问权
设置安全备份
-
-
你未使用身份服务器
你似乎正在试图连接到另一个主服务器。你想要登出吗?
你已经跟上了!
@@ -1096,7 +1063,6 @@
此内容已报告为不合适。
\n
\n如果你不希望再看到此用户的更多内容,你可以忽略他们以隐藏他们的消息。
-
忽略用户
全部消息(嘈杂)
全部消息
@@ -1295,7 +1261,6 @@
验证 %s
已验证 %s
正在等待 %s…
-
此聊天室的消息未经端对端加密。
该聊天室的消息已被端对端加密。
\n
@@ -1446,7 +1411,6 @@
密钥已是最新!
保存到优盘或者备份盘
复制到你的个人云存储
-
如果你现在取消,那么当你失去登录权限时也会丢失加密的信息和数据。
\n
\n你也可以通过设置菜单来建立保护备份以及管理你的密钥。
@@ -1754,7 +1718,6 @@
%2$d 的 %1$d
旋转和裁剪
添加图像自
-
授予许可
撤销我的许可
你已同意发送电子邮件和电话号码到身份服务器以从你的联系人发现其他用户。
@@ -1823,7 +1786,6 @@
已勾选
已选中
活跃通话(%1$s)
-
需要重新验证
删除失败的消息
你确定要取消发送消息吗?
@@ -1987,7 +1949,6 @@
${app_name} 要求你输入凭据才能执行此操作。
呼叫转移时发生错误
先询问
-
查找电话号码时发生了错误
此通话已结束
%1$s 拒绝了此通话
@@ -2075,7 +2036,6 @@
输入你想要探索的新服务器的名称。
添加一个新的服务器
你的服务器
-
抱歉,尝试加入 %s 时发生了一个错误
空间地址
查看和管理这个空间的地址。
@@ -2272,7 +2232,6 @@
投票问题或主题
创建投票
投票
-
向 %s 发送电子邮件和电话号码
您的联系人是私密的。 要从您的联系人中发现用户,我们需要您的许可才能将联系信息发送到您的身份服务器。
已退出此会话!
@@ -2321,4 +2280,8 @@
\n你可以阅读我们所有的条款 %s。
帮助改进 ${app_name}
启用
+ 不允许加入此房间
+
+ - 修改服务器 %d 的 ACLs
+
\ No newline at end of file
diff --git a/vector/src/main/res/values/donottranslate.xml b/vector/src/main/res/values/donottranslate.xml
index 968d01e717..d8e06459c8 100755
--- a/vector/src/main/res/values/donottranslate.xml
+++ b/vector/src/main/res/values/donottranslate.xml
@@ -1,15 +1,7 @@
- Debug screen
-
…
- +
- :
-
- ********
-
- #
Not implemented yet in ${app_name}
@@ -30,7 +22,9 @@
Add a profile picture
You can change this anytime.
-
+ Let\'s go
+ You\'re all set!
+ Your preferences have been saved.
Save and continue
Skip this step
diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml
index dc52d2cf5d..551e5961ec 100644
--- a/vector/src/main/res/values/strings.xml
+++ b/vector/src/main/res/values/strings.xml
@@ -1,5 +1,5 @@
-
+
%s\'s invitation
Your invitation
%1$s created the room
@@ -597,7 +597,7 @@
Continue
- List members
+ Members
Jump to unread
@@ -731,6 +731,8 @@
Tip: Long tap a message and use “%s”.
From a Thread
+ Threads Approaching Beta 🎉
+ We’re getting closer to releasing a public Beta for Threads.\n\nAs we prepare for it, we need to make some changes: threads created before this point will be displayed as regular replies.\n\nThis will be a one-off transition as Threads are now part of the Matrix specification.
Search
@@ -2781,7 +2783,7 @@
Explore rooms
Add rooms
- Leave Space
+ Leave
Are you sure you want to leave %s?
You are the only person here. If you leave, no one will be able to join in the future, including you.
You won\'t be able to rejoin unless you are re-invited.
@@ -2924,9 +2926,21 @@
Share location
Location
- Share location
+
+ Share location
Map
- Share location
+
+ Share location
+ Pin of selected location on map
+ Zoom to current location
+ Share my current location
+ Share my current location
+ Share live location
+ Share live location
+ Share this location
+ Share this location
+ Allow access
+ If you’d like to share your Live location, ${app_name} needs location access all the time when the app is in the background.\nWe will only access your location for the duration that you choose.
${app_name} could not access your location
${app_name} could not access your location. Please try again later.
Open with
@@ -2934,6 +2948,8 @@
Once enabled you will be able to send your location to any room
Render user locations in the timeline
Failed to load map
+ Live location enabled
+ Stop
Show Message bubbles
diff --git a/vector/src/main/res/values/strings_login_v2.xml b/vector/src/main/res/values/strings_login_v2.xml
index 5d1e14d73e..c84455a665 100644
--- a/vector/src/main/res/values/strings_login_v2.xml
+++ b/vector/src/main/res/values/strings_login_v2.xml
@@ -19,7 +19,6 @@
Enter an email associated to your Matrix account
Choose a new password
Please choose an identifier
- Your identifier will be used to connect to your Matrix account
Once your account is created, your identifier cannot be modified. However you will be able to change your display name.
If you\'re not sure, select this option
Element Matrix Server and others
diff --git a/vector/src/main/res/xml/riotx_provider_paths.xml b/vector/src/main/res/xml/riotx_provider_paths.xml
deleted file mode 100644
index 7d3fcb2203..0000000000
--- a/vector/src/main/res/xml/riotx_provider_paths.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/vector/src/main/res/xml/vector_settings_keyword_view.xml b/vector/src/main/res/xml/vector_settings_keyword_view.xml
deleted file mode 100644
index ed6ed8e32a..0000000000
--- a/vector/src/main/res/xml/vector_settings_keyword_view.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
\ No newline at end of file
diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml
index 16a7689b96..b150e8874e 100644
--- a/vector/src/main/res/xml/vector_settings_labs.xml
+++ b/vector/src/main/res/xml/vector_settings_labs.xml
@@ -123,8 +123,8 @@
diff --git a/vector/src/test/java/im/vector/app/features/location/LocationDataTest.kt b/vector/src/test/java/im/vector/app/features/location/LocationDataTest.kt
index e273c0b3c9..6b97b715db 100644
--- a/vector/src/test/java/im/vector/app/features/location/LocationDataTest.kt
+++ b/vector/src/test/java/im/vector/app/features/location/LocationDataTest.kt
@@ -22,6 +22,7 @@ import org.amshove.kluent.shouldBeTrue
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.LocationAsset
import org.matrix.android.sdk.api.session.room.model.message.LocationAssetType
+import org.matrix.android.sdk.api.session.room.model.message.LocationInfo
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
class LocationDataTest {
@@ -64,13 +65,24 @@ class LocationDataTest {
@Test
fun selfLocationTest() {
- val contentWithNullAsset = MessageLocationContent(body = "", geoUri = "", locationAsset = null)
+ val contentWithNullAsset = MessageLocationContent(body = "", geoUri = "")
contentWithNullAsset.isSelfLocation().shouldBeTrue()
- val contentWithNullAssetType = MessageLocationContent(body = "", geoUri = "", locationAsset = LocationAsset(type = null))
+ val contentWithNullAssetType = MessageLocationContent(body = "", geoUri = "", unstableLocationAsset = LocationAsset(type = null))
contentWithNullAssetType.isSelfLocation().shouldBeTrue()
- val contentWithSelfAssetType = MessageLocationContent(body = "", geoUri = "", locationAsset = LocationAsset(type = LocationAssetType.SELF))
+ val contentWithSelfAssetType = MessageLocationContent(body = "", geoUri = "", unstableLocationAsset = LocationAsset(type = LocationAssetType.SELF))
contentWithSelfAssetType.isSelfLocation().shouldBeTrue()
}
+
+ @Test
+ fun unstablePrefixTest() {
+ val geoUri = "geo :12.34,56.78;13.56"
+
+ val contentWithUnstablePrefixes = MessageLocationContent(body = "", geoUri = "", unstableLocationInfo = LocationInfo(geoUri = geoUri))
+ contentWithUnstablePrefixes.getBestLocationInfo()?.geoUri.shouldBeEqualTo(geoUri)
+
+ val contentWithStablePrefixes = MessageLocationContent(body = "", geoUri = "", locationInfo = LocationInfo(geoUri = geoUri))
+ contentWithStablePrefixes.getBestLocationInfo()?.geoUri.shouldBeEqualTo(geoUri)
+ }
}
diff --git a/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt
new file mode 100644
index 0000000000..015a27b0c8
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.location.domain.usecase
+
+import com.airbnb.mvrx.test.MvRxTestRule
+import im.vector.app.features.location.LocationData
+import im.vector.app.test.fakes.FakeSession
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.OverrideMockKs
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class CompareLocationsUseCaseTest {
+
+ @get:Rule
+ val mvRxTestRule = MvRxTestRule()
+
+ private val session = FakeSession()
+
+ @OverrideMockKs
+ lateinit var compareLocationsUseCase: CompareLocationsUseCase
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun `given 2 very near locations when calling execute then these locations are considered as equal`() = runBlockingTest {
+ // Given
+ val location1 = LocationData(
+ latitude = 48.858269,
+ longitude = 2.294551,
+ uncertainty = null
+ )
+ val location2 = LocationData(
+ latitude = 48.858275,
+ longitude = 2.294547,
+ uncertainty = null
+ )
+ // When
+ val areEqual = compareLocationsUseCase.execute(location1, location2)
+
+ // Then
+ assert(areEqual)
+ }
+
+ @Test
+ fun `given 2 far away locations when calling execute then these locations are considered as not equal`() = runBlockingTest {
+ // Given
+ val location1 = LocationData(
+ latitude = 48.858269,
+ longitude = 2.294551,
+ uncertainty = null
+ )
+ val location2 = LocationData(
+ latitude = 48.861777,
+ longitude = 2.289348,
+ uncertainty = null
+ )
+ // When
+ val areEqual = compareLocationsUseCase.execute(location1, location2)
+
+ // Then
+ assert(areEqual.not())
+ }
+}
diff --git a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt
index 62a38146fc..f6c322af40 100644
--- a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt
+++ b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt
@@ -20,29 +20,45 @@ import android.net.Uri
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
+import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.test.MvRxTestRule
-import im.vector.app.features.DefaultVectorOverrides
import im.vector.app.features.login.ReAuthHelper
+import im.vector.app.features.login.SignMode
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeAnalyticsTracker
import im.vector.app.test.fakes.FakeAuthenticationService
import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory
import im.vector.app.test.fakes.FakeHomeServerHistoryService
+import im.vector.app.test.fakes.FakeRegisterActionHandler
+import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.FakeUriFilenameResolver
import im.vector.app.test.fakes.FakeVectorFeatures
+import im.vector.app.test.fakes.FakeVectorOverrides
+import im.vector.app.test.fixtures.aHomeServerCapabilities
import im.vector.app.test.test
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.matrix.android.sdk.api.auth.registration.FlowResult
+import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
+import org.matrix.android.sdk.api.auth.registration.RegistrationResult
+import org.matrix.android.sdk.api.auth.registration.Stage
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
private const val A_DISPLAY_NAME = "a display name"
private const val A_PICTURE_FILENAME = "a-picture.png"
private val AN_ERROR = RuntimeException("an error!")
+private val A_LOADABLE_REGISTER_ACTION = RegisterAction.StartRegistration
+private val A_NON_LOADABLE_REGISTER_ACTION = RegisterAction.CheckIfEmailHasBeenValidated(delayMillis = -1L)
+private val A_RESULT_IGNORED_REGISTER_ACTION = RegisterAction.AddThreePid(RegisterThreePid.Email("an email"))
+private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canChangeDisplayName = true, canChangeAvatar = true)
+private val AN_IGNORED_FLOW_RESULT = FlowResult(missingStages = emptyList(), completedStages = emptyList())
+private val ANY_CONTINUING_REGISTRATION_RESULT = RegistrationResult.FlowResponse(AN_IGNORED_FLOW_RESULT)
class OnboardingViewModelTest {
@@ -55,6 +71,8 @@ class OnboardingViewModelTest {
private val fakeSession = FakeSession()
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession)
+ private val fakeAuthenticationService = FakeAuthenticationService()
+ private val fakeRegisterActionHandler = FakeRegisterActionHandler()
lateinit var viewModel: OnboardingViewModel
@@ -64,7 +82,7 @@ class OnboardingViewModelTest {
}
@Test
- fun `when handling PostViewEvent then emits contents as view event`() = runBlockingTest {
+ fun `when handling PostViewEvent, then emits contents as view event`() = runBlockingTest {
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome))
@@ -75,44 +93,182 @@ class OnboardingViewModelTest {
}
@Test
- fun `when handling display name update then updates upstream user display name`() = runBlockingTest {
+ fun `given supports changing display name, when handling PersonalizeProfile, then emits contents choose display name`() = runBlockingTest {
+ val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = true, supportsChangingProfilePicture = false))
+ viewModel = createViewModel(initialState)
+ val test = viewModel.test(this)
+
+ viewModel.handle(OnboardingAction.PersonalizeProfile)
+
+ test
+ .assertEvents(OnboardingViewEvents.OnChooseDisplayName)
+ .finish()
+ }
+
+ @Test
+ fun `given only supports changing profile picture, when handling PersonalizeProfile, then emits contents choose profile picture`() = runBlockingTest {
+ val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = false, supportsChangingProfilePicture = true))
+ viewModel = createViewModel(initialState)
+ val test = viewModel.test(this)
+
+ viewModel.handle(OnboardingAction.PersonalizeProfile)
+
+ test
+ .assertEvents(OnboardingViewEvents.OnChooseProfilePicture)
+ .finish()
+ }
+
+ @Test
+ fun `when handling SignUp then sets sign mode to sign up and starts registration`() = runBlockingTest {
+ givenRegistrationResultFor(RegisterAction.StartRegistration, ANY_CONTINUING_REGISTRATION_RESULT)
+ val test = viewModel.test(this)
+
+ viewModel.handle(OnboardingAction.UpdateSignMode(SignMode.SignUp))
+
+ test
+ .assertStatesChanges(
+ initialState,
+ { copy(signMode = SignMode.SignUp) },
+ { copy(asyncRegistration = Loading()) },
+ { copy(asyncRegistration = Uninitialized) }
+ )
+ .assertEvents(OnboardingViewEvents.RegistrationFlowResult(ANY_CONTINUING_REGISTRATION_RESULT.flowResult, isRegistrationStarted = true))
+ .finish()
+ }
+
+ @Test
+ fun `given register action requires more steps, when handling action, then posts next steps`() = runBlockingTest {
+ val test = viewModel.test(this)
+ givenRegistrationResultFor(A_LOADABLE_REGISTER_ACTION, ANY_CONTINUING_REGISTRATION_RESULT)
+
+ viewModel.handle(OnboardingAction.PostRegisterAction(A_LOADABLE_REGISTER_ACTION))
+
+ test
+ .assertStatesChanges(
+ initialState,
+ { copy(asyncRegistration = Loading()) },
+ { copy(asyncRegistration = Uninitialized) }
+ )
+ .assertEvents(OnboardingViewEvents.RegistrationFlowResult(ANY_CONTINUING_REGISTRATION_RESULT.flowResult, isRegistrationStarted = true))
+ .finish()
+ }
+
+ @Test
+ fun `given register action is non loadable, when handling action, then posts next steps without loading`() = runBlockingTest {
+ val test = viewModel.test(this)
+ givenRegistrationResultFor(A_NON_LOADABLE_REGISTER_ACTION, ANY_CONTINUING_REGISTRATION_RESULT)
+
+ viewModel.handle(OnboardingAction.PostRegisterAction(A_NON_LOADABLE_REGISTER_ACTION))
+
+ test
+ .assertState(initialState)
+ .assertEvents(OnboardingViewEvents.RegistrationFlowResult(ANY_CONTINUING_REGISTRATION_RESULT.flowResult, isRegistrationStarted = true))
+ .finish()
+ }
+
+ @Test
+ fun `given register action ignores result, when handling action, then does nothing on success`() = runBlockingTest {
+ val test = viewModel.test(this)
+ givenRegistrationResultFor(A_RESULT_IGNORED_REGISTER_ACTION, RegistrationResult.FlowResponse(AN_IGNORED_FLOW_RESULT))
+
+ viewModel.handle(OnboardingAction.PostRegisterAction(A_RESULT_IGNORED_REGISTER_ACTION))
+
+ test
+ .assertStatesChanges(
+ initialState,
+ { copy(asyncRegistration = Loading()) },
+ { copy(asyncRegistration = Uninitialized) }
+ )
+ .assertNoEvents()
+ .finish()
+ }
+
+ @Test
+ fun `when registering account, then updates state and emits account created event`() = runBlockingTest {
+ givenRegistrationResultFor(A_LOADABLE_REGISTER_ACTION, RegistrationResult.Success(fakeSession))
+ givenSuccessfullyCreatesAccount(A_HOMESERVER_CAPABILITIES)
+ val test = viewModel.test(this)
+
+ viewModel.handle(OnboardingAction.PostRegisterAction(A_LOADABLE_REGISTER_ACTION))
+
+ test
+ .assertStatesChanges(
+ initialState,
+ { copy(asyncRegistration = Loading()) },
+ { copy(asyncLoginAction = Success(Unit), personalizationState = A_HOMESERVER_CAPABILITIES.toPersonalisationState()) },
+ { copy(asyncLoginAction = Success(Unit), asyncRegistration = Uninitialized) }
+ )
+ .assertEvents(OnboardingViewEvents.OnAccountCreated)
+ .finish()
+ }
+
+ @Test
+ fun `given registration has started and has dummy step to do, when handling action, then ignores other steps and executes dummy`() = runBlockingTest {
+ givenSuccessfulRegistrationForStartAndDummySteps(missingStages = listOf(Stage.Dummy(mandatory = true)))
+ val test = viewModel.test(this)
+
+ viewModel.handle(OnboardingAction.PostRegisterAction(A_LOADABLE_REGISTER_ACTION))
+
+ test
+ .assertStatesChanges(
+ initialState,
+ { copy(asyncRegistration = Loading()) },
+ { copy(asyncLoginAction = Success(Unit), personalizationState = A_HOMESERVER_CAPABILITIES.toPersonalisationState()) },
+ { copy(asyncRegistration = Uninitialized) }
+ )
+ .assertEvents(OnboardingViewEvents.OnAccountCreated)
+ .finish()
+ }
+
+ @Test
+ fun `given changing profile picture is supported, when updating display name, then updates upstream user display name and moves to choose profile picture`() = runBlockingTest {
+ val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = true))
+ viewModel = createViewModel(personalisedInitialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test
- .assertStates(
- initialState,
- initialState.copy(asyncDisplayName = Loading()),
- initialState.copy(
- asyncDisplayName = Success(Unit),
- personalizationState = initialState.personalizationState.copy(displayName = A_DISPLAY_NAME)
- )
- )
- .assertEvents(OnboardingViewEvents.OnDisplayNameUpdated)
+ .assertStatesChanges(personalisedInitialState, expectedSuccessfulDisplayNameUpdateStates())
+ .assertEvents(OnboardingViewEvents.OnChooseProfilePicture)
.finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
}
@Test
- fun `given upstream failure when handling display name update then emits failure event`() = runBlockingTest {
+ fun `given changing profile picture is not supported, when updating display name, then updates upstream user display name and completes personalization`() = runBlockingTest {
+ val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = false))
+ viewModel = createViewModel(personalisedInitialState)
+ val test = viewModel.test(this)
+
+ viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
+
+ test
+ .assertStatesChanges(personalisedInitialState, expectedSuccessfulDisplayNameUpdateStates())
+ .assertEvents(OnboardingViewEvents.OnPersonalizationComplete)
+ .finish()
+ fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
+ }
+
+ @Test
+ fun `given upstream failure, when handling display name update, then emits failure event`() = runBlockingTest {
val test = viewModel.test(this)
fakeSession.fakeProfileService.givenSetDisplayNameErrors(AN_ERROR)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test
- .assertStates(
+ .assertStatesChanges(
initialState,
- initialState.copy(asyncDisplayName = Loading()),
- initialState.copy(asyncDisplayName = Fail(AN_ERROR)),
+ { copy(asyncDisplayName = Loading()) },
+ { copy(asyncDisplayName = Fail(AN_ERROR)) },
)
.assertEvents(OnboardingViewEvents.Failure(AN_ERROR))
.finish()
}
@Test
- fun `when handling profile picture selected then updates selected picture state`() = runBlockingTest {
+ fun `when handling profile picture selected, then updates selected picture state`() = runBlockingTest {
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.ProfilePictureSelected(fakeUri.instance))
@@ -127,7 +283,7 @@ class OnboardingViewModelTest {
}
@Test
- fun `given a selected picture when handling save selected profile picture then updates upstream avatar and completes personalization`() = runBlockingTest {
+ fun `given a selected picture, when handling save selected profile picture, then updates upstream avatar and completes personalization`() = runBlockingTest {
val initialStateWithPicture = givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME)
viewModel = createViewModel(initialStateWithPicture)
val test = viewModel.test(this)
@@ -142,7 +298,7 @@ class OnboardingViewModelTest {
}
@Test
- fun `given upstream update avatar fails when saving selected profile picture then emits failure event`() = runBlockingTest {
+ fun `given upstream update avatar fails, when saving selected profile picture, then emits failure event`() = runBlockingTest {
fakeSession.fakeProfileService.givenUpdateAvatarErrors(AN_ERROR)
val initialStateWithPicture = givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME)
viewModel = createViewModel(initialStateWithPicture)
@@ -157,7 +313,7 @@ class OnboardingViewModelTest {
}
@Test
- fun `given no selected picture when saving selected profile picture then emits failure event`() = runBlockingTest {
+ fun `given no selected picture, when saving selected profile picture, then emits failure event`() = runBlockingTest {
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.SaveSelectedProfilePicture)
@@ -169,7 +325,7 @@ class OnboardingViewModelTest {
}
@Test
- fun `when handling profile picture skipped then completes personalization`() = runBlockingTest {
+ fun `when handling profile skipped, then completes personalization`() = runBlockingTest {
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateProfilePictureSkipped)
@@ -184,7 +340,7 @@ class OnboardingViewModelTest {
return OnboardingViewModel(
state,
fakeContext.instance,
- FakeAuthenticationService(),
+ fakeAuthenticationService,
fakeActiveSessionHolder.instance,
FakeHomeServerConnectionConfigFactory().instance,
ReAuthHelper(),
@@ -193,7 +349,8 @@ class OnboardingViewModelTest {
FakeVectorFeatures(),
FakeAnalyticsTracker(),
fakeUriFilenameResolver.instance,
- DefaultVectorOverrides()
+ fakeRegisterActionHandler.instance,
+ FakeVectorOverrides()
)
}
@@ -214,4 +371,43 @@ class OnboardingViewModelTest {
state.copy(asyncProfilePicture = Loading()),
state.copy(asyncProfilePicture = Fail(cause))
)
+
+ private fun expectedSuccessfulDisplayNameUpdateStates(): List OnboardingViewState> {
+ return listOf(
+ { copy(asyncDisplayName = Loading()) },
+ { copy(asyncDisplayName = Success(Unit), personalizationState = personalizationState.copy(displayName = A_DISPLAY_NAME)) }
+ )
+ }
+
+ private fun givenSuccessfulRegistrationForStartAndDummySteps(missingStages: List) {
+ val flowResult = FlowResult(missingStages = missingStages, completedStages = emptyList())
+ givenRegistrationResultsFor(listOf(
+ A_LOADABLE_REGISTER_ACTION to RegistrationResult.FlowResponse(flowResult),
+ RegisterAction.RegisterDummy to RegistrationResult.Success(fakeSession)
+ ))
+ givenSuccessfullyCreatesAccount(A_HOMESERVER_CAPABILITIES)
+ }
+
+ private fun givenSuccessfullyCreatesAccount(homeServerCapabilities: HomeServerCapabilities) {
+ fakeSession.fakeHomeServerCapabilitiesService.givenCapabilities(homeServerCapabilities)
+ fakeActiveSessionHolder.expectSetsActiveSession(fakeSession)
+ fakeAuthenticationService.expectReset()
+ fakeSession.expectStartsSyncing()
+ }
+
+ private fun givenRegistrationResultFor(action: RegisterAction, result: RegistrationResult) {
+ givenRegistrationResultsFor(listOf(action to result))
+ }
+
+ private fun givenRegistrationResultsFor(results: List>) {
+ fakeAuthenticationService.givenRegistrationStarted(true)
+ val registrationWizard = FakeRegistrationWizard()
+ fakeAuthenticationService.givenRegistrationWizard(registrationWizard)
+ fakeRegisterActionHandler.givenResultsFor(registrationWizard, results)
+ }
}
+
+private fun HomeServerCapabilities.toPersonalisationState() = PersonalizationState(
+ supportsChangingDisplayName = canChangeDisplayName,
+ supportsChangingProfilePicture = canChangeAvatar
+)
diff --git a/vector/src/test/java/im/vector/app/features/onboarding/RegistrationActionHandlerTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/RegistrationActionHandlerTest.kt
new file mode 100644
index 0000000000..2ca9aaef07
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/features/onboarding/RegistrationActionHandlerTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.features.onboarding
+
+import im.vector.app.test.fakes.FakeRegistrationWizard
+import im.vector.app.test.fakes.FakeSession
+import io.mockk.coVerifyAll
+import kotlinx.coroutines.test.runBlockingTest
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.Test
+import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
+import org.matrix.android.sdk.api.auth.registration.RegistrationResult
+import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
+
+private val A_SESSION = FakeSession()
+private val AN_EXPECTED_RESULT = RegistrationResult.Success(A_SESSION)
+private const val A_USERNAME = "a username"
+private const val A_PASSWORD = "a password"
+private const val AN_INITIAL_DEVICE_NAME = "a device name"
+private const val A_CAPTCHA_RESPONSE = "a captcha response"
+private const val A_PID_CODE = "a pid code"
+private const val EMAIL_VALIDATED_DELAY = 10000L
+private val A_PID_TO_REGISTER = RegisterThreePid.Email("an email")
+
+class RegistrationActionHandlerTest {
+
+ @Test
+ fun `when handling register action then delegates to wizard`() = runBlockingTest {
+ val cases = listOf(
+ case(RegisterAction.StartRegistration) { getRegistrationFlow() },
+ case(RegisterAction.CaptchaDone(A_CAPTCHA_RESPONSE)) { performReCaptcha(A_CAPTCHA_RESPONSE) },
+ case(RegisterAction.AcceptTerms) { acceptTerms() },
+ case(RegisterAction.RegisterDummy) { dummy() },
+ case(RegisterAction.AddThreePid(A_PID_TO_REGISTER)) { addThreePid(A_PID_TO_REGISTER) },
+ case(RegisterAction.SendAgainThreePid) { sendAgainThreePid() },
+ case(RegisterAction.ValidateThreePid(A_PID_CODE)) { handleValidateThreePid(A_PID_CODE) },
+ case(RegisterAction.CheckIfEmailHasBeenValidated(EMAIL_VALIDATED_DELAY)) { checkIfEmailHasBeenValidated(EMAIL_VALIDATED_DELAY) },
+ case(RegisterAction.CreateAccount(A_USERNAME, A_PASSWORD, AN_INITIAL_DEVICE_NAME)) {
+ createAccount(A_USERNAME, A_PASSWORD, AN_INITIAL_DEVICE_NAME)
+ }
+ )
+
+ cases.forEach { testSuccessfulActionDelegation(it) }
+ }
+
+ private suspend fun testSuccessfulActionDelegation(case: Case) {
+ val registrationActionHandler = RegistrationActionHandler()
+ val fakeRegistrationWizard = FakeRegistrationWizard()
+ fakeRegistrationWizard.givenSuccessFor(result = A_SESSION, case.expect)
+
+ val result = registrationActionHandler.handleRegisterAction(fakeRegistrationWizard, case.action)
+
+ coVerifyAll { case.expect(fakeRegistrationWizard) }
+ result shouldBeEqualTo AN_EXPECTED_RESULT
+ }
+}
+
+private fun case(action: RegisterAction, expect: suspend RegistrationWizard.() -> RegistrationResult) = Case(action, expect)
+
+private class Case(val action: RegisterAction, val expect: suspend RegistrationWizard.() -> RegistrationResult)
diff --git a/vector/src/test/java/im/vector/app/test/Extensions.kt b/vector/src/test/java/im/vector/app/test/Extensions.kt
index 3ff041dc11..67eff7ca11 100644
--- a/vector/src/test/java/im/vector/app/test/Extensions.kt
+++ b/vector/src/test/java/im/vector/app/test/Extensions.kt
@@ -55,6 +55,25 @@ class ViewModelTest(
return this
}
+ fun assertStatesChanges(initial: S, vararg expected: S.() -> S): ViewModelTest {
+ return assertStatesChanges(initial, expected.toList())
+ }
+
+ /**
+ * Asserts the expected states are in the same order as the actual state emissions
+ * Each expected lambda is given the previous expected state, starting with the initial
+ */
+ fun assertStatesChanges(initial: S, expected: List S>): ViewModelTest {
+ val reducedExpectedStates = expected.fold(mutableListOf(initial)) { acc, curr ->
+ val next = curr.invoke(acc.last())
+ acc.add(next)
+ acc
+ }
+
+ states.assertValues(reducedExpectedStates)
+ return this
+ }
+
fun assertStates(expected: List): ViewModelTest {
states.assertValues(expected)
return this
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt
index 4b2264752a..d0825a0043 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionHolder.kt
@@ -18,7 +18,9 @@ package im.vector.app.test.fakes
import im.vector.app.core.di.ActiveSessionHolder
import io.mockk.every
+import io.mockk.justRun
import io.mockk.mockk
+import org.matrix.android.sdk.api.session.Session
class FakeActiveSessionHolder(
private val fakeSession: FakeSession = FakeSession()
@@ -26,4 +28,8 @@ class FakeActiveSessionHolder(
val instance = mockk {
every { getActiveSession() } returns fakeSession
}
+
+ fun expectSetsActiveSession(session: Session) {
+ justRun { instance.setActiveSession(session) }
+ }
}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt
index 9cdd7c9136..10daf5de1e 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt
@@ -16,7 +16,23 @@
package im.vector.app.test.fakes
+import io.mockk.coJustRun
+import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.AuthenticationService
+import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
-class FakeAuthenticationService : AuthenticationService by mockk()
+class FakeAuthenticationService : AuthenticationService by mockk() {
+
+ fun givenRegistrationWizard(registrationWizard: RegistrationWizard) {
+ every { getRegistrationWizard() } returns registrationWizard
+ }
+
+ fun givenRegistrationStarted(started: Boolean) {
+ every { isRegistrationStarted } returns started
+ }
+
+ fun expectReset() {
+ coJustRun { reset() }
+ }
+}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeHomeServerCapabilitiesService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeHomeServerCapabilitiesService.kt
new file mode 100644
index 0000000000..006789f62b
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeHomeServerCapabilitiesService.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.test.fakes
+
+import io.mockk.every
+import io.mockk.mockk
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
+
+class FakeHomeServerCapabilitiesService : HomeServerCapabilitiesService by mockk() {
+
+ fun givenCapabilities(homeServerCapabilities: HomeServerCapabilities) {
+ every { getHomeServerCapabilities() } returns homeServerCapabilities
+ }
+}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRegisterActionHandler.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRegisterActionHandler.kt
new file mode 100644
index 0000000000..8d595d91e9
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRegisterActionHandler.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.test.fakes
+
+import im.vector.app.features.onboarding.RegisterAction
+import im.vector.app.features.onboarding.RegistrationActionHandler
+import io.mockk.coEvery
+import io.mockk.mockk
+import org.matrix.android.sdk.api.auth.registration.RegistrationResult
+import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
+
+class FakeRegisterActionHandler {
+
+ val instance = mockk()
+
+ fun givenResultsFor(wizard: RegistrationWizard, result: List>) {
+ coEvery { instance.handleRegisterAction(wizard, any()) } answers { call ->
+ val actionArg = call.invocation.args[1] as RegisterAction
+ result.first { it.first == actionArg }.second
+ }
+ }
+}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRegistrationWizard.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRegistrationWizard.kt
new file mode 100644
index 0000000000..4e6e511abb
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRegistrationWizard.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.test.fakes
+
+import io.mockk.coEvery
+import io.mockk.mockk
+import org.matrix.android.sdk.api.auth.registration.RegistrationResult
+import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
+import org.matrix.android.sdk.api.session.Session
+
+class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) {
+
+ fun givenSuccessFor(result: Session, expect: suspend RegistrationWizard.() -> RegistrationResult) {
+ coEvery { expect(this@FakeRegistrationWizard) } returns RegistrationResult.Success(result)
+ }
+}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt
index 952a75cbeb..1fff67e982 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt
@@ -17,10 +17,13 @@
package im.vector.app.test.fakes
import android.net.Uri
+import im.vector.app.core.extensions.configureAndStart
+import im.vector.app.core.extensions.startSyncing
import im.vector.app.core.extensions.vectorStore
import im.vector.app.features.session.VectorSessionStore
import im.vector.app.test.testCoroutineDispatchers
import io.mockk.coEvery
+import io.mockk.coJustRun
import io.mockk.mockk
import io.mockk.mockkStatic
import org.matrix.android.sdk.api.session.Session
@@ -28,6 +31,7 @@ import org.matrix.android.sdk.api.session.Session
class FakeSession(
val fakeCryptoService: FakeCryptoService = FakeCryptoService(),
val fakeProfileService: FakeProfileService = FakeProfileService(),
+ val fakeHomeServerCapabilitiesService: FakeHomeServerCapabilitiesService = FakeHomeServerCapabilitiesService(),
val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService()
) : Session by mockk(relaxed = true) {
@@ -42,6 +46,7 @@ class FakeSession(
override val coroutineDispatchers = testCoroutineDispatchers
override suspend fun setDisplayName(userId: String, newDisplayName: String) = fakeProfileService.setDisplayName(userId, newDisplayName)
override suspend fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String) = fakeProfileService.updateAvatar(userId, newAvatarUri, fileName)
+ override fun getHomeServerCapabilities() = fakeHomeServerCapabilitiesService.getHomeServerCapabilities()
fun givenVectorStore(vectorSessionStore: VectorSessionStore) {
coEvery {
@@ -50,4 +55,11 @@ class FakeSession(
vectorSessionStore
}
}
+
+ fun expectStartsSyncing() {
+ coJustRun {
+ this@FakeSession.configureAndStart(any(), startSyncing = true)
+ this@FakeSession.startSyncing(any())
+ }
+ }
}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt
index 265941a531..b6e06bcdda 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt
@@ -23,5 +23,5 @@ class FakeVectorFeatures : VectorFeatures {
override fun isOnboardingAlreadyHaveAccountSplashEnabled() = true
override fun isOnboardingSplashCarouselEnabled() = true
override fun isOnboardingUseCaseEnabled() = true
- override fun isOnboardingPersonalizeEnabled() = false
+ override fun isOnboardingPersonalizeEnabled() = true
}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorOverrides.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorOverrides.kt
new file mode 100644
index 0000000000..b4dfbe1d8c
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorOverrides.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.test.fakes
+
+import im.vector.app.features.DefaultVectorOverrides
+import im.vector.app.features.VectorOverrides
+
+class FakeVectorOverrides : VectorOverrides by DefaultVectorOverrides()
diff --git a/vector/src/test/java/im/vector/app/test/fixtures/HomeserverCapabilityFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/HomeserverCapabilityFixture.kt
new file mode 100644
index 0000000000..a4d9869a89
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/test/fixtures/HomeserverCapabilityFixture.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 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 im.vector.app.test.fixtures
+
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.api.session.homeserver.RoomVersionCapabilities
+
+fun aHomeServerCapabilities(
+ canChangePassword: Boolean = true,
+ canChangeDisplayName: Boolean = true,
+ canChangeAvatar: Boolean = true,
+ canChange3pid: Boolean = true,
+ maxUploadFileSize: Long = 100L,
+ lastVersionIdentityServerSupported: Boolean = false,
+ defaultIdentityServerUrl: String? = null,
+ roomVersions: RoomVersionCapabilities? = null
+) = HomeServerCapabilities(
+ canChangePassword,
+ canChangeDisplayName,
+ canChangeAvatar,
+ canChange3pid,
+ maxUploadFileSize,
+ lastVersionIdentityServerSupported,
+ defaultIdentityServerUrl,
+ roomVersions
+)