Merge branch 'release/1.4.6' into main

This commit is contained in:
ganfra 2022-03-23 16:24:01 +01:00
commit 7c27ce5e88
430 changed files with 9963 additions and 3640 deletions

View File

@ -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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/cache@v2 - uses: actions/cache@v2
with: with:
path: | path: |
@ -49,7 +49,7 @@ jobs:
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
# Only runs on main, no concurrency. # Only runs on main, no concurrency.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/cache@v2 - uses: actions/cache@v2
with: with:
path: | path: |

View File

@ -7,5 +7,5 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time. # No concurrency required, this is a prerequisite to other actions and should run every time.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1 - uses: gradle/wrapper-validation-action@v1

View File

@ -14,50 +14,6 @@ env:
-Porg.gradle.jvmargs=-Xmx4g -Porg.gradle.jvmargs=-Xmx4g
-Porg.gradle.parallel=false -Porg.gradle.parallel=false
jobs: 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 # Run Android Tests
integration-tests: integration-tests:
name: Matrix SDK - Running Integration Tests name: Matrix SDK - Running Integration Tests
@ -68,7 +24,7 @@ jobs:
api-level: [ 28 ] api-level: [ 28 ]
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1 - uses: gradle/wrapper-validation-action@v1
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
with: with:
@ -87,11 +43,11 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Start synapse server - name: Start synapse server
run: | uses: michaelkaye/setup-matrix-synapse@v0.3.0
pip install matrix-synapse with:
curl https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh -o start.sh uploadLogs: true
chmod 777 start.sh httpPort: 8080
./start.sh --no-rate-limit disableRateLimiting: true
# package: org.matrix.android.sdk.session # package: org.matrix.android.sdk.session
- name: Run integration tests for Matrix SDK [org.matrix.android.sdk.session] API[${{ matrix.api-level }}] - name: Run integration tests for Matrix SDK [org.matrix.android.sdk.session] API[${{ matrix.api-level }}]
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
@ -260,7 +216,7 @@ jobs:
api-level: [ 28 ] api-level: [ 28 ]
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python 3.8 - name: Set up Python 3.8
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
@ -274,10 +230,11 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Start synapse server - name: Start synapse server
run: | uses: michaelkaye/setup-matrix-synapse@v0.3.0
pip install matrix-synapse with:
curl -sL https://raw.githubusercontent.com/matrix-org/synapse/develop/demo/start.sh \ uploadLogs: true
| 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 httpPort: 8080
disableRateLimiting: true
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
with: with:
distribution: 'adopt' distribution: 'adopt'
@ -308,9 +265,10 @@ jobs:
failure_screenshots/ failure_screenshots/
codecov-units: codecov-units:
name: Unit tests with code coverage
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
with: with:
distribution: 'adopt' distribution: 'adopt'
@ -333,12 +291,13 @@ jobs:
build/reports/jacoco/allCodeCoverageReport/allCodeCoverageReport.xml build/reports/jacoco/allCodeCoverageReport/allCodeCoverageReport.xml
sonarqube: sonarqube:
name: Sonarqube upload
runs-on: macos-latest runs-on: macos-latest
if: always() if: always()
needs: needs:
- codecov-units - codecov-units
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-java@v2 - uses: actions/setup-java@v2
with: with:
distribution: 'adopt' distribution: 'adopt'
@ -362,13 +321,11 @@ jobs:
# Notify the channel about scheduled runs, do not notify for manually triggered runs # Notify the channel about scheduled runs, do not notify for manually triggered runs
notify: notify:
name: Notify matrix
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- integration-tests - integration-tests
- ui-tests - ui-tests
# - unit-tests
- build-android-test-matrix-sdk
- build-android-test-app
- sonarqube - sonarqube
if: always() && github.event_name != 'workflow_dispatch' if: always() && github.event_name != 'workflow_dispatch'
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
@ -379,4 +336,4 @@ jobs:
matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }} matrix_access_token: ${{ secrets.ELEMENT_ANDROID_NOTIFICATION_ACCESS_TOKEN }}
matrix_room_id: ${{ secrets.ELEMENT_ANDROID_INTERNAL_ROOM_ID }} 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}}" 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 }}<br />{{name}} {{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a>{{/if}}{{/with}}{{/each}}" html_template: "Nightly test run results: {{#each job_statuses }}{{#with this }}{{#if completed }}<br />{{icon conclusion}} {{name}} <font color='{{color conclusion}}'>{{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a></font>{{/if}}{{/with}}{{/each}}"

View File

@ -10,7 +10,7 @@ jobs:
name: Project Check Suite name: Project Check Suite
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Run code quality check suite - name: Run code quality check suite
run: ./tools/check/check_code_quality.sh 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Run ktlint - name: Run ktlint
run: | run: |
./gradlew ktlintCheck --continue ./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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/cache@v2 - uses: actions/cache@v2
with: with:
path: | 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/cache@v2 - uses: actions/cache@v2
with: with:
path: | path: |

View File

@ -11,7 +11,7 @@ jobs:
if: github.repository == 'vector-im/element-android' if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python 3.8 - name: Set up Python 3.8
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
@ -38,7 +38,7 @@ jobs:
if: github.repository == 'vector-im/element-android' if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python 3.8 - name: Set up Python 3.8
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
@ -64,7 +64,7 @@ jobs:
if: github.repository == 'vector-im/element-android' if: github.repository == 'vector-im/element-android'
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Run analytics import script - name: Run analytics import script
run: ./tools/import_analytic_plan.sh run: ./tools/import_analytic_plan.sh
- name: Create Pull Request for analytics plan - name: Create Pull Request for analytics plan

View File

@ -12,6 +12,30 @@ env:
-Porg.gradle.parallel=false -Porg.gradle.parallel=false
jobs: 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: unit-tests:
name: Run Unit Tests name: Run Unit Tests
runs-on: ubuntu-latest 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/cache@v2 - uses: actions/cache@v2
with: with:
path: | path: |
@ -41,3 +65,20 @@ jobs:
( github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository ) ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository )
with: with:
files: ./**/build/test-results/**/*.xml 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 }}<br />{{icon conclusion }} {{name}} <font color='{{color conclusion }}'>{{conclusion}} at {{completed_at}} <a href=\"{{html_url}}\">[details]</a></font>{{/if}}{{/with}}{{/each}}"

View File

@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Update Gradle Wrapper - name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1 uses: gradle-update/update-gradle-wrapper-action@v1

View File

@ -11,6 +11,7 @@
<w>emoji</w> <w>emoji</w>
<w>emojis</w> <w>emojis</w>
<w>fdroid</w> <w>fdroid</w>
<w>ganfra</w>
<w>gplay</w> <w>gplay</w>
<w>hmac</w> <w>hmac</w>
<w>homeserver</w> <w>homeserver</w>
@ -18,6 +19,7 @@
<w>ktlint</w> <w>ktlint</w>
<w>linkified</w> <w>linkified</w>
<w>linkify</w> <w>linkify</w>
<w>manu</w>
<w>megolm</w> <w>megolm</w>
<w>msisdn</w> <w>msisdn</w>
<w>msisdns</w> <w>msisdns</w>

View File

@ -1,3 +1,57 @@
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
-------------
- 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) Changes in Element v1.4.4 (2022-03-09)
====================================== ======================================

View File

@ -58,6 +58,7 @@ ext.libs = [
'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle", 'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle",
'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle", 'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle",
'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle", 'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle",
'lifecycleRuntimeKtx' : "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle",
'datastore' : "androidx.datastore:datastore:1.0.0", 'datastore' : "androidx.datastore:datastore:1.0.0",
'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0", 'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0",
'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2", 'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
تغییرات اصلی در این نگارش: پیاده سازی نخستین پیام‌های رشته‌ای. حباب‌های پیام.
گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.4.0

View File

@ -0,0 +1,2 @@
تغییرات اصلی در این نگارش: افزودن پشتیبانی به @room و نظرسنجی‌های فاش نشده در کنار تغییرات کوچک دیگر.
گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.4.2

View File

@ -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 - 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 - 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.
<b>Privacy and encrypted messaging</b> <b>Magánszféra és titkosított csevegés</b>
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. 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.
<b>Element can be self-hosted</b> <b>Element can be self-hosted</b>
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. 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.

View File

@ -0,0 +1,2 @@
Основные изменения в этой версии: Начальная реализация веток сообщений. Сообщения пузыри.
Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.4.0

View File

@ -0,0 +1,2 @@
Основные изменения в этой версии: добавлена поддержка @room и нераскрытых опросов, а также множество других мелких изменений.
Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.4.2

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0 distributionSha256Sum=a9a7b7baba105f6557c9dcf9c3c6e8f7e57e6b49889c5f1d133f015d0727e4be
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@ -59,7 +59,7 @@ dependencies {
implementation libs.jetbrains.coroutinesCore implementation libs.jetbrains.coroutinesCore
implementation libs.jetbrains.coroutinesAndroid implementation libs.jetbrains.coroutinesAndroid
testImplementation 'org.json:json:20211205' testImplementation 'org.json:json:20220320'
testImplementation libs.tests.junit testImplementation libs.tests.junit
androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espressoCore androidTestImplementation libs.androidx.espressoCore

View File

@ -20,7 +20,6 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.view.ContextMenu import android.view.ContextMenu
import android.view.Menu
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@ -77,10 +76,7 @@ internal abstract class ValueItem : EpoxyModelWithHolder<ValueItem.Holder>() {
menuInfo: ContextMenu.ContextMenuInfo? menuInfo: ContextMenu.ContextMenuInfo?
) { ) {
if (copyValue != null) { if (copyValue != null) {
val menuItem = menu?.add( val menuItem = menu?.add(R.string.copy_value)
Menu.NONE, R.id.copy_value,
Menu.NONE, R.string.copy_value
)
val clipService = val clipService =
v?.context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager v?.context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
menuItem?.setOnMenuItemClickListener { menuItem?.setOnMenuItemClickListener {

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/copy_value"
android:title="@string/copy_value" />
</menu>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="copy_value">Copy Value</string> <string name="copy_value">Copy Value</string>
</resources> </resources>

View File

@ -71,19 +71,6 @@
android:enabled="false" android:enabled="false"
android:text="Destructive disabled" /> android:text="Destructive disabled" />
<Button
style="@style/Widget.Vector.Button.Unelevated.Bot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Bot" />
<Button
style="@style/Widget.Vector.Button.Unelevated.Bot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="Bot disabled" />
<Button <Button
style="@style/Widget.Vector.Button.Outlined" style="@style/Widget.Vector.Button.Outlined"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -98,19 +85,6 @@
android:enabled="false" android:enabled="false"
android:text="Outline disabled" /> android:text="Outline disabled" />
<Button
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Poll " />
<Button
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="Poll disabled" />
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -74,7 +74,7 @@
android:padding="16dp"> android:padding="16dp">
<Button <Button
style="@style/Widget.Vector.Button.Outlined.SocialLogin.Google.Dark" style="@style/Widget.Vector.Button.Outlined.SocialLogin.Google.Light"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Continue with XXX" /> android:text="Continue with XXX" />

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#4285F4" android:state_enabled="true"/>
<item android:color="@color/vctr_disabled_view_color_light" android:state_enabled="false"/>
<item android:color="#3367D6" android:state_pressed="true"/>
<item android:color="#4285F4" android:state_focused="true"/>
</selector>

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="DialpadKeyNumberStyle">
<item name="android:textColor">?attr/vctr_content_primary</item>
<item name="android:textSize">@dimen/dialpad_key_numbers_default_size</item>
<item name="android:fontFamily">sans-serif</item>
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginBottom">@dimen/dialpad_key_number_default_margin_bottom</item>
<item name="android:gravity">center</item>
</style>
<style name="DialpadKeyLettersStyle">
<item name="android:textColor">?attr/vctr_content_secondary</item>
<item name="android:textSize">@dimen/dialpad_key_letters_size</item>
<item name="android:fontFamily">sans-serif-regular</item>
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:gravity">center_horizontal</item>
</style>
<style name="DialpadKeyPoundStyle" parent="DialpadKeyNumberStyle">
<item name="android:textSize">@dimen/dialpad_key_pound_size</item>
<item name="android:layout_marginBottom">@dimen/dialpad_symbol_margin_bottom</item>
</style>
<style name="DialpadKeyStarStyle" parent="DialpadKeyNumberStyle">
<item name="android:textSize">@dimen/dialpad_key_star_size</item>
<item name="android:layout_marginBottom">@dimen/dialpad_symbol_margin_bottom</item>
</style>
</resources>

View File

@ -9,10 +9,6 @@
<color name="soft_resource_limit_exceeded">#2f9edb</color> <color name="soft_resource_limit_exceeded">#2f9edb</color>
<color name="hard_resource_limit_exceeded">?colorError</color> <color name="hard_resource_limit_exceeded">?colorError</color>
<!-- Button color -->
<color name="button_bot_background_color">#14368BD6</color>
<color name="button_bot_enabled_text_color">@color/palette_azure</color>
<!-- Notification (do not depends on theme) --> <!-- Notification (do not depends on theme) -->
<color name="notification_accent_color">@color/palette_azure</color> <color name="notification_accent_color">@color/palette_azure</color>
<color name="key_share_req_accent_color">@color/palette_melon</color> <color name="key_share_req_accent_color">@color/palette_melon</color>
@ -22,7 +18,6 @@
<color name="bg_call_screen_blur">#99000000</color> <color name="bg_call_screen_blur">#99000000</color>
<color name="bg_call_screen">#27303A</color> <color name="bg_call_screen">#27303A</color>
<color name="vctr_notice_secondary">#FF61708B</color>
<color name="vctr_notice_secondary_alpha12">#1E61708B</color> <color name="vctr_notice_secondary_alpha12">#1E61708B</color>
<!-- Other useful color --> <!-- Other useful color -->
@ -83,16 +78,6 @@
<color name="vctr_touch_guard_bg_dark">#BF000000</color> <color name="vctr_touch_guard_bg_dark">#BF000000</color>
<color name="vctr_touch_guard_bg_black">#BF000000</color> <color name="vctr_touch_guard_bg_black">#BF000000</color>
<attr name="vctr_attachment_selector_background" format="color" />
<color name="vctr_attachment_selector_background_light">#FFFFFFFF</color>
<color name="vctr_attachment_selector_background_dark">#FF22262E</color>
<color name="vctr_attachment_selector_background_black">#FF090A0C</color>
<attr name="vctr_attachment_selector_border" format="color" />
<color name="vctr_attachment_selector_border_light">#FFE9EDF1</color>
<color name="vctr_attachment_selector_border_dark">#FF22262E</color>
<color name="vctr_attachment_selector_border_black">#FF090A0C</color>
<attr name="vctr_room_active_widgets_banner_bg" format="color" /> <attr name="vctr_room_active_widgets_banner_bg" format="color" />
<color name="vctr_room_active_widgets_banner_bg_light">#EBEFF5</color> <color name="vctr_room_active_widgets_banner_bg_light">#EBEFF5</color>
<color name="vctr_room_active_widgets_banner_bg_dark">#27303A</color> <color name="vctr_room_active_widgets_banner_bg_dark">#27303A</color>
@ -107,9 +92,7 @@
<color name="vctr_waiting_background_color_light">#AAAAAAAA</color> <color name="vctr_waiting_background_color_light">#AAAAAAAA</color>
<color name="vctr_waiting_background_color_dark">#55555555</color> <color name="vctr_waiting_background_color_dark">#55555555</color>
<attr name="vctr_disabled_view_color" format="color" />
<color name="vctr_disabled_view_color_light">#EEEEEE</color> <color name="vctr_disabled_view_color_light">#EEEEEE</color>
<color name="vctr_disabled_view_color_dark">#61708B</color>
<attr name="vctr_reaction_background_off" format="color" /> <attr name="vctr_reaction_background_off" format="color" />
<color name="vctr_reaction_background_off_light">#FFF3F8FD</color> <color name="vctr_reaction_background_off_light">#FFF3F8FD</color>
@ -139,4 +122,18 @@
<color name="vctr_presence_indicator_offline_light">@color/palette_gray_100</color> <color name="vctr_presence_indicator_offline_light">@color/palette_gray_100</color>
<color name="vctr_presence_indicator_offline_dark">@color/palette_gray_450</color> <color name="vctr_presence_indicator_offline_dark">@color/palette_gray_450</color>
<attr name="vctr_presence_indicator_online" format="color" />
<color name="vctr_presence_indicator_online_light">@color/palette_element_green</color>
<color name="vctr_presence_indicator_online_dark">@color/palette_element_green</color>
<!-- Location sharing colors -->
<attr name="vctr_live_location" format="color" />
<color name="vctr_live_location_light">@color/palette_prune</color>
<color name="vctr_live_location_dark">@color/palette_prune</color>
<!-- Shield colors -->
<color name="shield_color_trust">#0DBD8B</color>
<color name="shield_color_black">#17191C</color>
<color name="shield_color_warning">#FF4B55</color>
</resources> </resources>

View File

@ -9,20 +9,11 @@
<dimen name="layout_vertical_margin_big">32dp</dimen> <dimen name="layout_vertical_margin_big">32dp</dimen>
<dimen name="profile_avatar_size">50dp</dimen> <dimen name="profile_avatar_size">50dp</dimen>
<dimen name="floating_action_button_margin">16dp</dimen>
<dimen name="navigation_view_height">196dp</dimen>
<dimen name="navigation_avatar_top_margin">44dp</dimen>
<dimen name="item_decoration_left_margin">72dp</dimen>
<dimen name="item_event_message_state_size">16dp</dimen> <dimen name="item_event_message_state_size">16dp</dimen>
<dimen name="item_event_message_media_button_size">32dp</dimen> <dimen name="item_event_message_media_button_size">32dp</dimen>
<dimen name="chat_avatar_size">40dp</dimen>
<dimen name="member_list_avatar_size">60dp</dimen>
<dimen name="quote_width">4dp</dimen>
<dimen name="quote_gap">8dp</dimen> <dimen name="quote_gap">8dp</dimen>
<dimen name="drawable_padding_small">8dp</dimen>
<item name="dialog_width_ratio" format="float" type="dimen">0.75</item> <item name="dialog_width_ratio" format="float" type="dimen">0.75</item>
@ -70,4 +61,10 @@
<item name="ftue_auth_profile_picture_height" format="float" type="dimen">0.15</item> <item name="ftue_auth_profile_picture_height" format="float" type="dimen">0.15</item>
<item name="ftue_auth_profile_picture_icon_height" format="float" type="dimen">0.05</item> <item name="ftue_auth_profile_picture_icon_height" format="float" type="dimen">0.05</item>
<!-- Location sharing -->
<dimen name="location_sharing_option_default_padding">10dp</dimen>
<dimen name="location_sharing_locate_button_margin_vertical">16dp</dimen>
<dimen name="location_sharing_locate_button_margin_horizontal">12dp</dimen>
<dimen name="location_sharing_compass_button_margin_horizontal">8dp</dimen>
</resources> </resources>

View File

@ -1,8 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<!-- Define all the colors used across the Element Android project <!--
Source: https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=1338%3A17947 --> Define all the colors used across the Element Android project
Source: https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=1338%3A17947
Some colors are not used, but we want the palette to be complete so we ignore the lint error
UnusedResources for all the resources in this file
-->
<!-- For all themes --> <!-- For all themes -->
<color name="palette_azure">#368BD6</color> <color name="palette_azure">#368BD6</color>
@ -15,6 +19,7 @@
<color name="palette_element_green">#0DBD8B</color> <color name="palette_element_green">#0DBD8B</color>
<color name="palette_white">#FFFFFF</color> <color name="palette_white">#FFFFFF</color>
<color name="palette_vermilion">#FF5B55</color> <color name="palette_vermilion">#FF5B55</color>
<!-- (unused) -->
<color name="palette_ems">#7E69FF</color> <color name="palette_ems">#7E69FF</color>
<color name="palette_aqua">#2DC2C5</color> <color name="palette_aqua">#2DC2C5</color>
<color name="palette_prune">#5C56F5</color> <color name="palette_prune">#5C56F5</color>
@ -27,6 +32,7 @@
<color name="palette_gray_150">#8D97A5</color> <color name="palette_gray_150">#8D97A5</color>
<color name="palette_gray_200">#737D8C</color> <color name="palette_gray_200">#737D8C</color>
<color name="palette_black_900">#17191C</color> <color name="palette_black_900">#17191C</color>
<!-- (unused) -->
<color name="palette_ice">#F4F9FD</color> <color name="palette_ice">#F4F9FD</color>
<!-- For dark themes --> <!-- For dark themes -->

View File

@ -35,7 +35,6 @@
<attr name="vctr_system" format="color" /> <attr name="vctr_system" format="color" />
<color name="element_system_light">@color/palette_gray_25</color> <color name="element_system_light">@color/palette_gray_25</color>
<color name="element_system_dark">@color/palette_black_950</color> <color name="element_system_dark">@color/palette_black_950</color>
<color name="element_system_black">@color/palette_black_950</color>
<color name="element_background_light">@color/palette_white</color> <color name="element_background_light">@color/palette_white</color>
<color name="element_background_dark">@color/palette_black_800</color> <color name="element_background_dark">@color/palette_black_800</color>
@ -54,5 +53,4 @@
<color name="element_room_01">@color/palette_verde</color> <color name="element_room_01">@color/palette_verde</color>
<color name="element_room_02">@color/palette_azure</color> <color name="element_room_02">@color/palette_azure</color>
<color name="element_room_03">@color/palette_grape</color> <color name="element_room_03">@color/palette_grape</color>
</resources> </resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LocationSharingOptionView">
<attr name="locShareIcon" format="reference" />
<attr name="locShareIconBackground" format="reference" />
<attr name="locShareIconBackgroundTint" format="color" />
<attr name="locShareIconPadding" format="dimension" />
<attr name="locShareIconDescription" format="string" />
<attr name="locShareTitle" format="string" />
</declare-styleable>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MapTilerMapView">
<attr name="showLocateButton" format="boolean" />
</declare-styleable>
</resources>

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AttachmentTypeSelectorButton">
<item name="android:layout_width">56dp</item>
<item name="android:layout_height">56dp</item>
<item name="android:scaleType">center</item>
</style>
<style name="AttachmentTypeSelectorLabel">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textColor">?vctr_content_primary</item>
<item name="android:textSize">14sp</item>
<item name="android:layout_marginTop">8dp</item>
</style>
</resources>

View File

@ -33,24 +33,6 @@
<!-- Keep default colors from the theme --> <!-- Keep default colors from the theme -->
</style> </style>
<style name="Widget.Vector.Button.Unelevated" parent="Widget.MaterialComponents.Button.UnelevatedButton">
<item name="android:paddingLeft">16dp</item>
<item name="android:paddingRight">16dp</item>
<item name="android:minWidth">94dp</item>
<item name="android:textAppearance">@style/TextAppearance.Vector.Button</item>
<item name="cornerRadius">8dp</item>
<item name="lineHeight">24sp</item>
</style>
<style name="Widget.Vector.Button.Unelevated.Bot">
<item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayBot</item>
</style>
<style name="VectorMaterialThemeOverlayBot">
<item name="colorPrimary">@color/button_bot_background_color</item>
<item name="colorOnPrimary">@color/button_bot_enabled_text_color</item>
</style>
<style name="Widget.Vector.Button.Text" parent="Widget.MaterialComponents.Button.TextButton"> <style name="Widget.Vector.Button.Text" parent="Widget.MaterialComponents.Button.TextButton">
<item name="colorControlHighlight">?colorSecondary</item> <item name="colorControlHighlight">?colorSecondary</item>
<item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayPositive</item> <item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayPositive</item>
@ -83,9 +65,4 @@
<item name="colorPrimary">?colorOnPrimary</item> <item name="colorPrimary">?colorOnPrimary</item>
</style> </style>
<style name="Widget.Vector.Button.Outlined.Poll">
<item name="android:minHeight">44dp</item>
<item name="cornerRadius">10dp</item>
</style>
</resources> </resources>

View File

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
<!-- This file contain styles to override the style from the library android-dialer-1.2.5.aar
This is why we ignore the lint error UnusedResources -->
<style name="DialpadKeyNumberStyle"> <style name="DialpadKeyNumberStyle">
<item name="android:textColor">?vctr_content_primary</item> <item name="android:textColor">?vctr_content_primary</item>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Widget.Vector.Button.Text.OnPrimary.LocationLive">
<item name="android:background">?selectableItemBackground</item>
<item name="android:textSize">12sp</item>
<item name="android:padding">0dp</item>
<item name="android:gravity">center</item>
</style>
</resources>

View File

@ -28,11 +28,6 @@
<item name="android:textColor">@color/black_54</item> <item name="android:textColor">@color/black_54</item>
</style> </style>
<style name="Widget.Vector.Button.Outlined.SocialLogin.Google.Dark">
<item name="android:backgroundTint">@color/button_social_google_background_selector_dark</item>
<item name="android:textColor">@android:color/white</item>
</style>
<style name="Widget.Vector.Button.Outlined.SocialLogin.Github"> <style name="Widget.Vector.Button.Outlined.SocialLogin.Github">
<item name="icon">@drawable/ic_social_github</item> <item name="icon">@drawable/ic_social_github</item>
</style> </style>

View File

@ -11,8 +11,6 @@
<item name="vctr_fab_label_stroke">@color/vctr_fab_label_stroke_black</item> <item name="vctr_fab_label_stroke">@color/vctr_fab_label_stroke_black</item>
<item name="vctr_fab_label_color">@color/vctr_fab_label_color_black</item> <item name="vctr_fab_label_color">@color/vctr_fab_label_color_black</item>
<item name="vctr_touch_guard_bg">@color/vctr_touch_guard_bg_black</item> <item name="vctr_touch_guard_bg">@color/vctr_touch_guard_bg_black</item>
<item name="vctr_attachment_selector_background">@color/vctr_attachment_selector_background_black</item>
<item name="vctr_attachment_selector_border">@color/vctr_attachment_selector_border_black</item>
<item name="vctr_room_active_widgets_banner_bg">@color/vctr_room_active_widgets_banner_bg_black</item> <item name="vctr_room_active_widgets_banner_bg">@color/vctr_room_active_widgets_banner_bg_black</item>
<item name="vctr_room_active_widgets_banner_text">@color/vctr_room_active_widgets_banner_text_black</item> <item name="vctr_room_active_widgets_banner_text">@color/vctr_room_active_widgets_banner_text_black</item>
<item name="vctr_reaction_background_off">@color/vctr_reaction_background_off_black</item> <item name="vctr_reaction_background_off">@color/vctr_reaction_background_off_black</item>

View File

@ -21,8 +21,6 @@
<item name="vctr_fab_label_color">@color/vctr_fab_label_color_dark</item> <item name="vctr_fab_label_color">@color/vctr_fab_label_color_dark</item>
<item name="vctr_touch_guard_bg">@color/vctr_touch_guard_bg_dark</item> <item name="vctr_touch_guard_bg">@color/vctr_touch_guard_bg_dark</item>
<item name="vctr_keys_backup_banner_accent_color">@color/vctr_keys_backup_banner_accent_color_dark</item> <item name="vctr_keys_backup_banner_accent_color">@color/vctr_keys_backup_banner_accent_color_dark</item>
<item name="vctr_attachment_selector_background">@color/vctr_attachment_selector_background_dark</item>
<item name="vctr_attachment_selector_border">@color/vctr_attachment_selector_border_dark</item>
<item name="vctr_room_active_widgets_banner_bg">@color/vctr_room_active_widgets_banner_bg_dark</item> <item name="vctr_room_active_widgets_banner_bg">@color/vctr_room_active_widgets_banner_bg_dark</item>
<item name="vctr_room_active_widgets_banner_text">@color/vctr_room_active_widgets_banner_text_dark</item> <item name="vctr_room_active_widgets_banner_text">@color/vctr_room_active_widgets_banner_text_dark</item>
<item name="vctr_reaction_background_off">@color/vctr_reaction_background_off_dark</item> <item name="vctr_reaction_background_off">@color/vctr_reaction_background_off_dark</item>
@ -45,6 +43,7 @@
<!-- Presence Indicator colors --> <!-- Presence Indicator colors -->
<item name="vctr_presence_indicator_offline">@color/vctr_presence_indicator_offline_dark</item> <item name="vctr_presence_indicator_offline">@color/vctr_presence_indicator_offline_dark</item>
<item name="vctr_presence_indicator_online">@color/vctr_presence_indicator_online_dark</item>
<!-- Some aliases --> <!-- Some aliases -->
<item name="vctr_header_background">?vctr_system</item> <item name="vctr_header_background">?vctr_system</item>
@ -145,6 +144,8 @@
<item name="android:actionButtonStyle">@style/Widget.Vector.ActionButton</item> <item name="android:actionButtonStyle">@style/Widget.Vector.ActionButton</item>
<!-- Location sharing -->
<item name="vctr_live_location">@color/vctr_live_location_dark</item>
</style> </style>
<style name="Theme.Vector.Dark" parent="Base.Theme.Vector.Dark" /> <style name="Theme.Vector.Dark" parent="Base.Theme.Vector.Dark" />

View File

@ -21,8 +21,6 @@
<item name="vctr_fab_label_color">@color/vctr_fab_label_color_light</item> <item name="vctr_fab_label_color">@color/vctr_fab_label_color_light</item>
<item name="vctr_touch_guard_bg">@color/vctr_touch_guard_bg_light</item> <item name="vctr_touch_guard_bg">@color/vctr_touch_guard_bg_light</item>
<item name="vctr_keys_backup_banner_accent_color">@color/vctr_keys_backup_banner_accent_color_light</item> <item name="vctr_keys_backup_banner_accent_color">@color/vctr_keys_backup_banner_accent_color_light</item>
<item name="vctr_attachment_selector_background">@color/vctr_attachment_selector_background_light</item>
<item name="vctr_attachment_selector_border">@color/vctr_attachment_selector_border_light</item>
<item name="vctr_room_active_widgets_banner_bg">@color/vctr_room_active_widgets_banner_bg_light</item> <item name="vctr_room_active_widgets_banner_bg">@color/vctr_room_active_widgets_banner_bg_light</item>
<item name="vctr_room_active_widgets_banner_text">@color/vctr_room_active_widgets_banner_text_light</item> <item name="vctr_room_active_widgets_banner_text">@color/vctr_room_active_widgets_banner_text_light</item>
<item name="vctr_reaction_background_off">@color/vctr_reaction_background_off_light</item> <item name="vctr_reaction_background_off">@color/vctr_reaction_background_off_light</item>
@ -45,6 +43,7 @@
<!-- Presence Indicator colors --> <!-- Presence Indicator colors -->
<item name="vctr_presence_indicator_offline">@color/vctr_presence_indicator_offline_light</item> <item name="vctr_presence_indicator_offline">@color/vctr_presence_indicator_offline_light</item>
<item name="vctr_presence_indicator_online">@color/vctr_presence_indicator_online_light</item>
<!-- Some aliases --> <!-- Some aliases -->
<item name="vctr_header_background">?vctr_system</item> <item name="vctr_header_background">?vctr_system</item>
@ -146,6 +145,8 @@
<item name="android:actionButtonStyle">@style/Widget.Vector.ActionButton</item> <item name="android:actionButtonStyle">@style/Widget.Vector.ActionButton</item>
<!-- Location sharing -->
<item name="vctr_live_location">@color/vctr_live_location_light</item>
</style> </style>
<style name="Theme.Vector.Light" parent="Base.Theme.Vector.Light" /> <style name="Theme.Vector.Light" parent="Base.Theme.Vector.Light" />

View File

@ -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.model.RoomSummary
import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState 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.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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.api.util.toOptional
@ -101,13 +102,18 @@ class FlowRoom(private val room: Room) {
return room.getLiveRoomNotificationState().asFlow() return room.getLiveRoomNotificationState().asFlow()
} }
fun liveThreadSummaries(): Flow<List<ThreadSummary>> {
return room.getAllThreadSummariesLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreadSummaries()
}
}
fun liveThreadList(): Flow<List<ThreadRootEvent>> { fun liveThreadList(): Flow<List<ThreadRootEvent>> {
return room.getAllThreadsLive().asFlow() return room.getAllThreadsLive().asFlow()
.startWith(room.coroutineDispatchers.io) { .startWith(room.coroutineDispatchers.io) {
room.getAllThreads() room.getAllThreads()
} }
} }
fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> { fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> {
return room.getMarkedThreadNotificationsLive().asFlow() return room.getMarkedThreadNotificationsLive().asFlow()
.startWith(room.coroutineDispatchers.io) { .startWith(room.coroutineDispatchers.io) {

View File

@ -31,12 +31,11 @@ android {
// that the app's state is completely cleared between tests. // that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true' testInstrumentationRunnerArguments clearPackageData: 'true'
buildConfigField "String", "SDK_VERSION", "\"1.4.4\"" buildConfigField "String", "SDK_VERSION", "\"1.4.6\""
buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\""
resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\"" buildConfigField "String", "GIT_SDK_REVISION_DATE", "\"${gitRevisionDate()}\""
resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\""
defaultConfig { defaultConfig {
consumerProguardFiles 'proguard-rules.pro' consumerProguardFiles 'proguard-rules.pro'
@ -167,7 +166,7 @@ dependencies {
implementation libs.apache.commonsImaging implementation libs.apache.commonsImaging
// Phone number https://github.com/google/libphonenumber // 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 libs.tests.junit
testImplementation 'org.robolectric:robolectric:4.7.3' 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 // Note: version sticks to 1.9.2 due to https://github.com/mockk/mockk/issues/281
testImplementation libs.mockk.mockk testImplementation libs.mockk.mockk
testImplementation libs.tests.kluent testImplementation libs.tests.kluent
implementation libs.jetbrains.coroutinesAndroid testImplementation libs.jetbrains.coroutinesTest
// Plant Timber tree for test // Plant Timber tree for test
testImplementation 'net.lachlanmckee:timber-junit-rule:1.0.1' testImplementation 'net.lachlanmckee:timber-junit-rule:1.0.1'
// Transitively required for mocking realm as monarchy doesn't expose Rx // Transitively required for mocking realm as monarchy doesn't expose Rx

View File

@ -23,7 +23,7 @@ object TestConstants {
const val TESTS_HOME_SERVER_URL = "http://10.0.2.2:8080" const val TESTS_HOME_SERVER_URL = "http://10.0.2.2:8080"
// Time out to use when waiting for server response. // 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 // 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 private const val AWAIT_TIME_OUT_WITH_DEBUGGER_MILLIS = 10 * 60_000

View File

@ -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<MessageContent>()?.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<MegolmBackupCreationInfo> {
bobKeysBackupService.prepareKeysBackupVersion(keyBackupPassword, null, it)
}
val version = testHelper.doSync<KeysVersion> {
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<String>()
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<KeysVersionResult?> {
keysBackupService.getVersion(version.version, it)
}
val importedResult = testHelper.doSync<ImportRoomKeysResult> {
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<String>()
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<EncryptedEventContent>()!!.sessionId!!
val secondSessionId = secondEventNewBobPov!!.root.content.toModel<EncryptedEventContent>()!!.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<MessageContent>()?.body?.startsWith(text) == true }
sentEventId = decryptedMsg?.eventId
decryptedMsg != null
}
timeline.dispose()
}
return sentEventId
}
private fun ensureMembersHaveJoined(aliceSession: Session, otherAccounts: List<Session>, 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<String>, session: Session, e2eRoomID: String, messagesText: List<String>) {
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<MessageContent>()?.body
}
}
}
}
private fun ensureIsDecrypted(sentEventIds: List<String>, 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<String>, 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")
}
}
}
}
}
}

View File

@ -21,7 +21,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.FixMethodOrder import org.junit.FixMethodOrder
import org.junit.Ignore
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters import org.junit.runners.MethodSorters
@ -41,7 +40,6 @@ class PreShareKeysTest : InstrumentedTest {
private val cryptoTestHelper = CryptoTestHelper(testHelper) private val cryptoTestHelper = CryptoTestHelper(testHelper)
@Test @Test
@Ignore("This test will be ignored until it is fixed")
fun ensure_outbound_session_happy_path() { fun ensure_outbound_session_happy_path() {
val testData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) val testData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true)
val e2eRoomID = testData.roomId val e2eRoomID = testData.roomId
@ -92,7 +90,7 @@ class PreShareKeysTest : InstrumentedTest {
// Just send a real message as test // Just send a real message as test
val sentEvent = testHelper.sendTextMessage(aliceSession.getRoom(e2eRoomID)!!, "Allo", 1).first() val sentEvent = testHelper.sendTextMessage(aliceSession.getRoom(e2eRoomID)!!, "Allo", 1).first()
assertEquals(megolmSessionId, sentEvent.root.content.toModel<EncryptedEventContent>()?.sessionId, "Unexpected megolm session") assertEquals("Unexpected megolm session", megolmSessionId, sentEvent.root.content.toModel<EncryptedEventContent>()?.sessionId,)
testHelper.waitWithLatch { latch -> testHelper.waitWithLatch { latch ->
testHelper.retryPeriodicallyWithLatch(latch) { testHelper.retryPeriodicallyWithLatch(latch) {
bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEvent.eventId)?.root?.getClearType() == EventType.MESSAGE bobSession.getRoom(e2eRoomID)?.getTimelineEvent(sentEvent.eventId)?.root?.getClearType() == EventType.MESSAGE

View File

@ -21,7 +21,6 @@ import org.amshove.kluent.shouldBe
import org.junit.Assert import org.junit.Assert
import org.junit.Before import org.junit.Before
import org.junit.FixMethodOrder import org.junit.FixMethodOrder
import org.junit.Ignore
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters import org.junit.runners.MethodSorters
@ -85,7 +84,6 @@ class UnwedgingTest : InstrumentedTest {
* -> This is automatically fixed after SDKs restarted the olm session * -> This is automatically fixed after SDKs restarted the olm session
*/ */
@Test @Test
@Ignore("This test will be ignored until it is fixed")
fun testUnwedging() { fun testUnwedging() {
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom() val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom()
@ -94,9 +92,7 @@ class UnwedgingTest : InstrumentedTest {
val bobSession = cryptoTestData.secondSession!! val bobSession = cryptoTestData.secondSession!!
val aliceCryptoStore = (aliceSession.cryptoService() as DefaultCryptoService).cryptoStoreForTesting val aliceCryptoStore = (aliceSession.cryptoService() as DefaultCryptoService).cryptoStoreForTesting
val olmDevice = (aliceSession.cryptoService() as DefaultCryptoService).olmDeviceForTest
// bobSession.cryptoService().setWarnOnUnknownDevices(false)
// aliceSession.cryptoService().setWarnOnUnknownDevices(false)
val roomFromBobPOV = bobSession.getRoom(aliceRoomId)!! val roomFromBobPOV = bobSession.getRoom(aliceRoomId)!!
val roomFromAlicePOV = aliceSession.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") Timber.i("## CRYPTO | testUnwedging: wedge the session now. Set crypto state like after the first message")
aliceCryptoStore.storeSession(OlmSessionWrapper(deserializeFromRealm<OlmSession>(oldSession)!!), bobSession.cryptoService().getMyDevice().identityKey()!!) aliceCryptoStore.storeSession(OlmSessionWrapper(deserializeFromRealm<OlmSession>(oldSession)!!), bobSession.cryptoService().getMyDevice().identityKey()!!)
olmDevice.clearOlmSessionCache()
Thread.sleep(6_000) Thread.sleep(6_000)
// Force new session, and key share // Force new session, and key share
@ -227,9 +224,11 @@ class UnwedgingTest : InstrumentedTest {
testHelper.waitWithLatch { testHelper.waitWithLatch {
testHelper.retryPeriodicallyWithLatch(it) { testHelper.retryPeriodicallyWithLatch(it) {
// we should get back the key and be able to decrypt // we should get back the key and be able to decrypt
val result = tryOrNull { val result = testHelper.runBlockingTest {
tryOrNull {
bobSession.cryptoService().decryptEvent(messagesReceivedByBob[0].root, "") bobSession.cryptoService().decryptEvent(messagesReceivedByBob[0].root, "")
} }
}
Timber.i("## CRYPTO | testUnwedging: decrypt result ${result?.clearEvent}") Timber.i("## CRYPTO | testUnwedging: decrypt result ${result?.clearEvent}")
result != null result != null
} }

View File

@ -97,7 +97,9 @@ class KeyShareTests : InstrumentedTest {
assert(receivedEvent!!.isEncrypted()) assert(receivedEvent!!.isEncrypted())
try { try {
commonTestHelper.runBlockingTest {
aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
}
fail("should fail") fail("should fail")
} catch (failure: Throwable) { } catch (failure: Throwable) {
} }
@ -152,7 +154,9 @@ class KeyShareTests : InstrumentedTest {
} }
try { try {
commonTestHelper.runBlockingTest {
aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
}
fail("should fail") fail("should fail")
} catch (failure: Throwable) { } catch (failure: Throwable) {
} }
@ -189,7 +193,9 @@ class KeyShareTests : InstrumentedTest {
} }
try { try {
commonTestHelper.runBlockingTest {
aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo") aliceSession2.cryptoService().decryptEvent(receivedEvent.root, "foo")
}
} catch (failure: Throwable) { } catch (failure: Throwable) {
fail("should have been able to decrypt") fail("should have been able to decrypt")
} }
@ -384,7 +390,11 @@ class KeyShareTests : InstrumentedTest {
val roomRoomBobPov = aliceSession.getRoom(roomId) val roomRoomBobPov = aliceSession.getRoom(roomId)
val beforeJoin = roomRoomBobPov!!.getTimelineEvent(secondEventId) 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) assert(dRes == null)
@ -395,7 +405,11 @@ class KeyShareTests : InstrumentedTest {
Thread.sleep(3_000) Thread.sleep(3_000)
// With the bug the first session would have improperly reshare that key :/ // 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<MessageContent>()?.body}") Log.d("#TEST", "KS: sgould not decrypt that ${beforeJoin.root.getClearContent().toModel<MessageContent>()?.body}")
assert(dRes?.clearEvent == null) assert(dRes?.clearEvent == null)
} }

View File

@ -93,7 +93,9 @@ class WithHeldTests : InstrumentedTest {
// Bob should not be able to decrypt because the keys is withheld // Bob should not be able to decrypt because the keys is withheld
try { try {
// .. might need to wait a bit for stability? // .. might need to wait a bit for stability?
testHelper.runBlockingTest {
bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "") bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
}
Assert.fail("This session should not be able to decrypt") Assert.fail("This session should not be able to decrypt")
} catch (failure: Throwable) { } catch (failure: Throwable) {
val type = (failure as MXCryptoError.Base).errorType val type = (failure as MXCryptoError.Base).errorType
@ -118,7 +120,9 @@ class WithHeldTests : InstrumentedTest {
// Previous message should still be undecryptable (partially withheld session) // Previous message should still be undecryptable (partially withheld session)
try { try {
// .. might need to wait a bit for stability? // .. might need to wait a bit for stability?
testHelper.runBlockingTest {
bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "") bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
}
Assert.fail("This session should not be able to decrypt") Assert.fail("This session should not be able to decrypt")
} catch (failure: Throwable) { } catch (failure: Throwable) {
val type = (failure as MXCryptoError.Base).errorType val type = (failure as MXCryptoError.Base).errorType
@ -165,7 +169,9 @@ class WithHeldTests : InstrumentedTest {
val eventBobPOV = bobSession.getRoom(testData.roomId)?.getTimelineEvent(eventId) val eventBobPOV = bobSession.getRoom(testData.roomId)?.getTimelineEvent(eventId)
try { try {
// .. might need to wait a bit for stability? // .. might need to wait a bit for stability?
testHelper.runBlockingTest {
bobSession.cryptoService().decryptEvent(eventBobPOV!!.root, "") bobSession.cryptoService().decryptEvent(eventBobPOV!!.root, "")
}
Assert.fail("This session should not be able to decrypt") Assert.fail("This session should not be able to decrypt")
} catch (failure: Throwable) { } catch (failure: Throwable) {
val type = (failure as MXCryptoError.Base).errorType val type = (failure as MXCryptoError.Base).errorType
@ -233,7 +239,11 @@ class WithHeldTests : InstrumentedTest {
testHelper.retryPeriodicallyWithLatch(latch) { testHelper.retryPeriodicallyWithLatch(latch) {
val timeLineEvent = bobSecondSession.getRoom(testData.roomId)?.getTimelineEvent(eventId)?.also { val timeLineEvent = bobSecondSession.getRoom(testData.roomId)?.getTimelineEvent(eventId)?.also {
// try to decrypt and force key request // 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<EncryptedEventContent>()?.sessionId sessionId = timeLineEvent?.root?.content?.toModel<EncryptedEventContent>()?.sessionId
timeLineEvent != null timeLineEvent != null

View File

@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.crypto.verification.qrcode
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
import org.junit.FixMethodOrder import org.junit.FixMethodOrder
import org.junit.Ignore
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.MethodSorters import org.junit.runners.MethodSorters
@ -39,6 +40,7 @@ import kotlin.coroutines.resume
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.JVM) @FixMethodOrder(MethodSorters.JVM)
@Ignore("This test is flaky ; see issue #5449")
class VerificationTest : InstrumentedTest { class VerificationTest : InstrumentedTest {
data class ExpectedResult( data class ExpectedResult(

View File

@ -60,7 +60,9 @@ class MarkdownParserTest : InstrumentedTest {
applicationFlavor = "TestFlavor", applicationFlavor = "TestFlavor",
roomDisplayNameFallbackProvider = TestRoomDisplayNameFallbackProvider() roomDisplayNameFallbackProvider = TestRoomDisplayNameFallbackProvider()
) )
)) ),
TestPermalinkService()
)
) )
@Test @Test

View File

@ -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<String>?, 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 -> "<a href=\"https://matrix.to/#/%1\$s\">%2\$s</a>"
MARKDOWN -> "[%2\$s](https://matrix.to/#/%1\$s)"
}
}
}

View File

@ -62,7 +62,11 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it) 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 chunk.timelineEvents.size shouldBeEqualTo 1
} }
} }
@ -74,8 +78,16 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it) realm.copyToRealm(it)
} }
chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) chunk.addTimelineEvent(
chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) 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 chunk.timelineEvents.size shouldBeEqualTo 1
} }
} }
@ -144,7 +156,11 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let { val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it) realm.copyToRealm(it)
} }
addTimelineEvent(roomId, fakeEvent, direction, emptyMap()) addTimelineEvent(
roomId = roomId,
eventEntity = fakeEvent,
direction = direction,
roomMemberContentsByUser = emptyMap())
} }
} }

View File

@ -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<TimelineEvent>) {
snapshot.firstOrNull { it.root.getClearType() in EventType.POLL_START }?.let { pollEvent ->
val pollEventId = pollEvent.eventId
val pollContent = pollEvent.root.content?.toModel<MessagePollContent>()
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")
}
}

View File

@ -60,7 +60,15 @@ data class MatrixConfiguration(
/** /**
* RoomDisplayNameFallbackProvider to provide default room display name. * 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,
) { ) {
/** /**

View File

@ -121,7 +121,7 @@ interface CryptoService {
fun discardOutboundSession(roomId: String) fun discardOutboundSession(roomId: String)
@Throws(MXCryptoError::class) @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<MXEventDecryptionResult>) fun decryptEventAsync(event: Event, timeline: String, callback: MatrixCallback<MXEventDecryptionResult>)

View File

@ -49,5 +49,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class AggregatedRelations( data class AggregatedRelations(
@Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null, @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
) )

View File

@ -201,7 +201,11 @@ data class Event(
*/ */
fun getDecryptedTextSummary(): String? { fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted" 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 { return when {
isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text) isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
isFileMessage() -> "sent a file." 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 fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
@ -372,7 +376,7 @@ fun Event.getRelationContent(): RelationDefaultContent? {
* Returns the poll question or null otherwise * Returns the poll question or null otherwise
*/ */
fun Event.getPollQuestion(): String? = fun Event.getPollQuestion(): String? =
getPollContent()?.pollCreationInfo?.question?.question getPollContent()?.getBestPollCreationInfo()?.question?.getBestQuestion()
/** /**
* Returns the relation content for a specific type or null otherwise * Returns the relation content for a specific type or null otherwise
@ -385,12 +389,12 @@ fun Event.isReply(): Boolean {
} }
fun Event.isReplyRenderedInThread(): 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 { fun Event.isEdition(): Boolean {
return getRelationContentForType(RelationType.REPLACE)?.eventId != null return getRelationContentForType(RelationType.REPLACE)?.eventId != null
@ -406,3 +410,5 @@ fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
fun Event.getPollContent(): MessagePollContent? { fun Event.getPollContent(): MessagePollContent? {
return content.toModel<MessagePollContent>() return content.toModel<MessagePollContent>()
} }
fun Event.supportsNotification() = this.getClearType() in EventType.MESSAGE + EventType.POLL_START

View File

@ -103,9 +103,9 @@ object EventType {
const val REACTION = "m.reaction" const val REACTION = "m.reaction"
// Poll // Poll
const val POLL_START = "org.matrix.msc3381.poll.start" val POLL_START = listOf("org.matrix.msc3381.poll.start", "m.poll.start")
const val POLL_RESPONSE = "org.matrix.msc3381.poll.response" val POLL_RESPONSE = listOf("org.matrix.msc3381.poll.response", "m.poll.response")
const val POLL_END = "org.matrix.msc3381.poll.end" val POLL_END = listOf("org.matrix.msc3381.poll.end", "m.poll.end")
// Unwedging // Unwedging
internal const val DUMMY = "m.dummy" internal const val DUMMY = "m.dummy"

View File

@ -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

View File

@ -30,7 +30,6 @@ object RelationType {
/** Lets you define an event which is a thread reply to an existing event.*/ /** Lets you define an event which is a thread reply to an existing event.*/
const val THREAD = "m.thread" 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.*/ /** Lets you define an event which adds a response to an existing event.*/
const val RESPONSE = "org.matrix.response" const val RESPONSE = "org.matrix.response"

View File

@ -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. * 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. * 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 { enum class RoomCapabilitySupport {

View File

@ -28,6 +28,11 @@ interface PermalinkService {
const val MATRIX_TO_URL_BASE = "https://matrix.to/#/" const val MATRIX_TO_URL_BASE = "https://matrix.to/#/"
} }
enum class SpanTemplateType {
HTML,
MARKDOWN
}
/** /**
* Creates a permalink for an event. * Creates a permalink for an event.
* Ex: "https://matrix.to/#/!nbzmcXAqpxBXjAdgoX:matrix.org/$1531497316352799BevdV:matrix.org" * 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 * @return the id from the url, ex: "@benoit:matrix.org", or null if the url is not a permalink
*/ */
fun getLinkedId(url: String): String? 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: "<a href=\"https://matrix.to/#/%1\$s\">%2\$s</a>" 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
} }

View File

@ -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.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService 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.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.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@ -47,6 +48,7 @@ import org.matrix.android.sdk.api.util.Optional
interface Room : interface Room :
TimelineService, TimelineService,
ThreadsService, ThreadsService,
ThreadsLocalService,
SendService, SendService,
DraftService, DraftService,
ReadService, ReadService,

View File

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.paging.PagedList import androidx.paging.PagedList
import kotlinx.coroutines.flow.Flow
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState 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.Membership
@ -216,6 +217,11 @@ interface RoomService {
pagedListConfig: PagedList.Config = defaultPagedListConfig, pagedListConfig: PagedList.Config = defaultPagedListConfig,
sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): UpdatableLivePageResult sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): UpdatableLivePageResult
/**
* Retrieve a flow on the number of rooms.
*/
fun getRoomCountFlow(queryParams: RoomSummaryQueryParams): Flow<Int>
/** /**
* TODO Doc * TODO Doc
*/ */

View File

@ -22,10 +22,8 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
interface UpdatableLivePageResult { interface UpdatableLivePageResult {
val livePagedList: LiveData<PagedList<RoomSummary>> val livePagedList: LiveData<PagedList<RoomSummary>>
fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams)
val liveBoundaries: LiveData<ResultBoundaries> val liveBoundaries: LiveData<ResultBoundaries>
var queryParams: RoomSummaryQueryParams
} }
data class ResultBoundaries( data class ResultBoundaries(

View File

@ -21,5 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class LocationAsset( data class LocationAsset(
@Json(name = "type") val type: LocationAssetType? = null @Json(name = "type") val type: String? = null
) )

View File

@ -16,11 +16,20 @@
package org.matrix.android.sdk.api.session.room.model.message 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 { * Used for pin drop location sharing.
@Json(name = "m.self") **/
SELF const val PIN = "m.pin"
} }

View File

@ -39,37 +39,47 @@ data class MessageLocationContent(
*/ */
@Json(name = "geo_uri") val geoUri: String, @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.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = 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 * See [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md)
* 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.
*/ */
@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) * 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.msc3488.ts") val unstableTs: Long? = null,
@Json(name = "m.ts") val ts: Long? = null,
@Json(name = "org.matrix.msc1767.text") val text: String? = 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 { ) : 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. * @return true if the location asset is a user location, not a generic one.
*/ */
fun isSelfLocation(): Boolean { fun isSelfLocation(): Boolean {
// Should behave like m.self if locationAsset is null // Should behave like m.self if locationAsset is null
val locationAsset = getBestLocationAsset()
return locationAsset?.type == null || locationAsset.type == LocationAssetType.SELF return locationAsset?.type == null || locationAsset.type == LocationAssetType.SELF
} }
} }

View File

@ -31,5 +31,9 @@ data class MessagePollContent(
@Json(name = "body") override val body: String = "", @Json(name = "body") override val body: String = "",
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null, @Json(name = "m.new_content") override val newContent: Content? = null,
@Json(name = "org.matrix.msc3381.poll.start") val pollCreationInfo: PollCreationInfo? = null @Json(name = "org.matrix.msc3381.poll.start") val unstablePollCreationInfo: PollCreationInfo? = null,
) : MessageContent @Json(name = "m.poll.start") val pollCreationInfo: PollCreationInfo? = null
) : MessageContent {
fun getBestPollCreationInfo() = pollCreationInfo ?: unstablePollCreationInfo
}

View File

@ -31,5 +31,9 @@ data class MessagePollResponseContent(
@Json(name = "body") override val body: String = "", @Json(name = "body") override val body: String = "",
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null, @Json(name = "m.new_content") override val newContent: Content? = null,
@Json(name = "org.matrix.msc3381.poll.response") val response: PollResponse? = null @Json(name = "org.matrix.msc3381.poll.response") val unstableResponse: PollResponse? = null,
) : MessageContent @Json(name = "m.response") val response: PollResponse? = null
) : MessageContent {
fun getBestResponse() = response ?: unstableResponse
}

View File

@ -22,5 +22,9 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class PollAnswer( data class PollAnswer(
@Json(name = "id") val id: String? = null, @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
}

View File

@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class PollCreationInfo( data class PollCreationInfo(
@Json(name = "question") val question: PollQuestion? = null, @Json(name = "question") val question: PollQuestion? = null,
@Json(name = "kind") val kind: PollType? = PollType.DISCLOSED, @Json(name = "kind") val kind: PollType? = PollType.DISCLOSED_UNSTABLE,
@Json(name = "max_selections") val maxSelections: Int = 1, @Json(name = "max_selections") val maxSelections: Int = 1,
@Json(name = "answers") val answers: List<PollAnswer>? = null @Json(name = "answers") val answers: List<PollAnswer>? = null
) )

View File

@ -21,5 +21,9 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class PollQuestion( 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
}

View File

@ -25,11 +25,17 @@ enum class PollType {
* Voters should see results as soon as they have voted. * Voters should see results as soon as they have voted.
*/ */
@Json(name = "org.matrix.msc3381.poll.disclosed") @Json(name = "org.matrix.msc3381.poll.disclosed")
DISCLOSED_UNSTABLE,
@Json(name = "m.poll.disclosed")
DISCLOSED, DISCLOSED,
/** /**
* Results should be only revealed when the poll is ended. * Results should be only revealed when the poll is ended.
*/ */
@Json(name = "org.matrix.msc3381.poll.undisclosed") @Json(name = "org.matrix.msc3381.poll.undisclosed")
UNDISCLOSED_UNSTABLE,
@Json(name = "m.poll.undisclosed")
UNDISCLOSED UNDISCLOSED
} }

View File

@ -26,5 +26,6 @@ data class ReactionInfo(
@Json(name = "key") val key: String, @Json(name = "key") val key: String,
// always null for reaction // always null for reaction
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, @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 ) : RelationContent

View File

@ -24,4 +24,10 @@ interface RelationContent {
val eventId: String? val eventId: String?
val inReplyTo: ReplyToContent? val inReplyTo: ReplyToContent?
val option: Int? val option: Int?
/**
* This flag indicates that the message should be rendered as a reply
* fallback, when isFallingBack = false
*/
val isFallingBack: Boolean?
} }

View File

@ -23,5 +23,8 @@ data class RelationDefaultContent(
@Json(name = "rel_type") override val type: String?, @Json(name = "rel_type") override val type: String?,
@Json(name = "event_id") override val eventId: String?, @Json(name = "event_id") override val eventId: String?,
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, @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 ) : RelationContent
fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false

View File

@ -163,13 +163,4 @@ interface RelationService {
autoMarkdown: Boolean = false, autoMarkdown: Boolean = false,
formattedText: String? = null, formattedText: String? = null,
eventReplied: TimelineEvent? = null): Cancelable? 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
} }

View File

@ -21,8 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ReplyToContent( data class ReplyToContent(
@Json(name = "event_id") val eventId: String? = null, @Json(name = "event_id") val eventId: String? = null
@Json(name = "render_in") val renderIn: List<String>? = null
) )
fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true

View File

@ -142,8 +142,9 @@ interface SendService {
* @param latitude required latitude of the location * @param latitude required latitude of the location
* @param longitude required longitude of the location * @param longitude required longitude of the location
* @param uncertainty Accuracy of the location in meters * @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 * Remove this failed message from the timeline

View File

@ -32,7 +32,6 @@ object RoomSummaryConstants {
EventType.CALL_ANSWER, EventType.CALL_ANSWER,
EventType.ENCRYPTED, EventType.ENCRYPTED,
EventType.STICKER, EventType.STICKER,
EventType.REACTION, EventType.REACTION
EventType.POLL_START ) + EventType.POLL_START
)
} }

View File

@ -17,51 +17,43 @@
package org.matrix.android.sdk.api.session.room.threads package org.matrix.android.sdk.api.session.room.threads
import androidx.lifecycle.LiveData 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. * This interface defines methods to interact with thread related features.
* It's implemented at the room level within the main timeline. * 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 { 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<List<TimelineEvent>> fun getAllThreadSummariesLive(): LiveData<List<ThreadSummary>>
/** /**
* 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<TimelineEvent> fun getAllThreadSummaries(): List<ThreadSummary>
/** /**
* Returns a [LiveData] list of all the marked unread threads that exists at the room level * Enhance the provided ThreadSummary[List] by adding the latest
*/
fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>>
/**
* Returns a list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotifications(): List<TimelineEvent>
/**
* 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 * message edition for that thread
* @return the enhanced [List] with edited updates * @return the enhanced [List] with edited updates
*/ */
fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> fun enhanceThreadWithEditions(threads: List<ThreadSummary>): List<ThreadSummary>
/** /**
* Marks the current thread as read in local DB. * Fetch all thread replies for the specified thread using the /relations api
* note: read receipts within threads are not yet supported with the API * @param rootThreadEventId the root thread eventId
* @param rootThreadEventId the root eventId of the current thread * @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()
} }

View File

@ -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<List<TimelineEvent>>
/**
* Returns a list of all the thread root TimelineEvents that exists at the room level
*/
fun getAllThreads(): List<TimelineEvent>
/**
* Returns a [LiveData] list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>>
/**
* Returns a list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotifications(): List<TimelineEvent>
/**
* 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<TimelineEvent>): List<TimelineEvent>
/**
* 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)
}

View File

@ -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)

View File

@ -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())

View File

@ -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
}

View File

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.session.room.timeline package org.matrix.android.sdk.api.session.room.timeline
import org.matrix.android.sdk.BuildConfig 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.Event
import org.matrix.android.sdk.api.session.events.model.EventType 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.events.model.RelationType
@ -54,6 +55,7 @@ data class TimelineEvent(
* It's not unique on the timeline as it's reset on each chunk. * It's not unique on the timeline as it's reset on each chunk.
*/ */
val displayIndex: Int, val displayIndex: Int,
var ownedByThreadChunk: Boolean = false,
val senderInfo: SenderInfo, val senderInfo: SenderInfo,
val annotations: EventAnnotationsSummary? = null, val annotations: EventAnnotationsSummary? = null,
val readReceipts: List<ReadReceipt> = emptyList() val readReceipts: List<ReadReceipt> = emptyList()
@ -135,7 +137,7 @@ fun TimelineEvent.getEditedEventId(): String? {
fun TimelineEvent.getLastMessageContent(): MessageContent? { fun TimelineEvent.getLastMessageContent(): MessageContent? {
return when (root.getClearType()) { return when (root.getClearType()) {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>() EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>() in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
} }
} }
@ -158,6 +160,13 @@ fun TimelineEvent.isSticker(): Boolean {
return root.isSticker() 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 * Get the latest message body, after a possible edition, stripping the reply prefix if necessary
*/ */

View File

@ -38,7 +38,7 @@ interface TimelineService {
/** /**
* Returns a snapshot of TimelineEvent event with eventId. * 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 * @param eventId the eventId to get the TimelineEvent
*/ */
fun getTimelineEvent(eventId: String): TimelineEvent? fun getTimelineEvent(eventId: String): TimelineEvent?

View File

@ -16,6 +16,7 @@
package org.matrix.android.sdk.api.session.threads 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 import org.matrix.android.sdk.api.session.room.sender.SenderInfo
/** /**
@ -26,7 +27,7 @@ data class ThreadDetails(
val isRootThread: Boolean = false, val isRootThread: Boolean = false,
val numberOfThreads: Int = 0, val numberOfThreads: Int = 0,
val threadSummarySenderInfo: SenderInfo? = null, val threadSummarySenderInfo: SenderInfo? = null,
val threadSummaryLatestTextMessage: String? = null, val threadSummaryLatestEvent: Event? = null,
val lastMessageTimestamp: Long? = null, val lastMessageTimestamp: Long? = null,
var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE, var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE,
val isThread: Boolean = false, val isThread: Boolean = false,

View File

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.util package org.matrix.android.sdk.api.util
import org.matrix.android.sdk.BuildConfig 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.group.model.GroupSummary
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary 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.model.RoomSummary
@ -199,6 +200,8 @@ fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName,
fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl)
fun SenderInfo.toMatrixItemOrNull() = tryOrNull { MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) }
fun SpaceChildInfo.toMatrixItem() = if (roomType == RoomType.SPACE) { fun SpaceChildInfo.toMatrixItem() = if (roomType == RoomType.SPACE) {
MatrixItem.SpaceItem(childRoomId, name ?: canonicalAlias, avatarUrl) MatrixItem.SpaceItem(childRoomId, name ?: canonicalAlias, avatarUrl)
} else { } else {

View File

@ -38,7 +38,7 @@ internal data class HomeServerVersion(
} }
companion object { 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? { internal fun parse(value: String): HomeServerVersion? {
val result = pattern.matchEntire(value) ?: return null 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_4_0 = HomeServerVersion(major = 0, minor = 4, patch = 0)
val r0_5_0 = HomeServerVersion(major = 0, minor = 5, 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 r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0)
val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0)
} }
} }

View File

@ -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_REQUIRE_IDENTITY_SERVER = "m.require_identity_server"
private const val FEATURE_ID_ACCESS_TOKEN = "m.id_access_token" 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_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 * Return true if the SDK supports this homeserver version
@ -68,6 +70,14 @@ internal fun Versions.isLoginAndRegistrationSupportedBySdk(): Boolean {
doesServerSeparatesAddAndBind() 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 * Return true if the server support the lazy loading of room members
* *

View File

@ -434,6 +434,14 @@ internal class DefaultCryptoService @Inject constructor(
val currentCount = syncResponse.deviceOneTimeKeysCount.signedCurve25519 ?: 0 val currentCount = syncResponse.deviceOneTimeKeysCount.signedCurve25519 ?: 0
oneTimeKeysUploader.updateOneTimeKeyCount(currentCount) 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. // 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 // 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 // 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 * @return the MXEventDecryptionResult data, or throw in case of error
*/ */
@Throws(MXCryptoError::class) @Throws(MXCryptoError::class)
override fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
return internalDecryptEvent(event, timeline) return internalDecryptEvent(event, timeline)
} }
@ -746,7 +754,7 @@ internal class DefaultCryptoService @Inject constructor(
* @return the MXEventDecryptionResult data, or null in case of error * @return the MXEventDecryptionResult data, or null in case of error
*/ */
@Throws(MXCryptoError::class) @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) return eventDecryptor.decryptEvent(event, timeline)
} }
@ -1364,6 +1372,9 @@ internal class DefaultCryptoService @Inject constructor(
@VisibleForTesting @VisibleForTesting
val cryptoStoreForTesting = cryptoStore val cryptoStoreForTesting = cryptoStore
@VisibleForTesting
val olmDeviceForTest = olmDevice
companion object { companion object {
const val CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS = 3_600_000 // one hour const val CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS = 3_600_000 // one hour
} }

View File

@ -21,14 +21,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers 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.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event 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.EventType
import org.matrix.android.sdk.api.session.events.model.toModel 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.EnsureOlmSessionsForDevicesAction
import org.matrix.android.sdk.internal.crypto.actions.MessageEncrypter 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.MXUsersDevicesMap
import org.matrix.android.sdk.internal.crypto.model.event.OlmEventContent import org.matrix.android.sdk.internal.crypto.model.event.OlmEventContent
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore 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 const val SEND_TO_DEVICE_RETRY_COUNT = 3
private val loggerTag = LoggerTag("CryptoSyncHandler", LoggerTag.CRYPTO)
@SessionScope @SessionScope
internal class EventDecryptor @Inject constructor( internal class EventDecryptor @Inject constructor(
private val cryptoCoroutineScope: CoroutineScope, private val cryptoCoroutineScope: CoroutineScope,
@ -47,13 +48,22 @@ internal class EventDecryptor @Inject constructor(
private val roomDecryptorProvider: RoomDecryptorProvider, private val roomDecryptorProvider: RoomDecryptorProvider,
private val messageEncrypter: MessageEncrypter, private val messageEncrypter: MessageEncrypter,
private val sendToDeviceTask: SendToDeviceTask, private val sendToDeviceTask: SendToDeviceTask,
private val deviceListManager: DeviceListManager,
private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction,
private val cryptoStore: IMXCryptoStore private val cryptoStore: IMXCryptoStore
) { ) {
// The date of the last time we forced establishment /**
// of a new session for each user:device. * Rate limit unwedge attempt, should we persist that?
private val lastNewSessionForcedDates = MXUsersDevicesMap<Long>() */
private val lastNewSessionForcedDates = mutableMapOf<WedgedDeviceInfo, Long>()
data class WedgedDeviceInfo(
val userId: String,
val senderKey: String?
)
private val wedgedDevices = mutableListOf<WedgedDeviceInfo>()
/** /**
* Decrypt an event * Decrypt an event
@ -63,7 +73,7 @@ internal class EventDecryptor @Inject constructor(
* @return the MXEventDecryptionResult data, or throw in case of error * @return the MXEventDecryptionResult data, or throw in case of error
*/ */
@Throws(MXCryptoError::class) @Throws(MXCryptoError::class)
fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
return internalDecryptEvent(event, timeline) return internalDecryptEvent(event, timeline)
} }
@ -91,38 +101,32 @@ internal class EventDecryptor @Inject constructor(
* @return the MXEventDecryptionResult data, or null in case of error * @return the MXEventDecryptionResult data, or null in case of error
*/ */
@Throws(MXCryptoError::class) @Throws(MXCryptoError::class)
private fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult { private suspend fun internalDecryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
val eventContent = event.content val eventContent = event.content
if (eventContent == null) { 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) throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE, MXCryptoError.BAD_ENCRYPTED_MESSAGE_REASON)
} else { } else {
val algorithm = eventContent["algorithm"]?.toString() val algorithm = eventContent["algorithm"]?.toString()
val alg = roomDecryptorProvider.getOrCreateRoomDecryptor(event.roomId, algorithm) val alg = roomDecryptorProvider.getOrCreateRoomDecryptor(event.roomId, algorithm)
if (alg == null) { if (alg == null) {
val reason = String.format(MXCryptoError.UNABLE_TO_DECRYPT_REASON, event.eventId, algorithm) 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) throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, reason)
} else { } else {
try { try {
return alg.decryptEvent(event, timeline) return alg.decryptEvent(event, timeline)
} catch (mxCryptoError: MXCryptoError) { } 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 (algorithm == MXCRYPTO_ALGORITHM_OLM) {
if (mxCryptoError is MXCryptoError.Base && if (mxCryptoError is MXCryptoError.Base &&
mxCryptoError.errorType == MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE) { mxCryptoError.errorType == MXCryptoError.ErrorType.BAD_ENCRYPTED_MESSAGE) {
// need to find sending device // need to find sending device
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
val olmContent = event.content.toModel<OlmEventContent>() val olmContent = event.content.toModel<OlmEventContent>()
cryptoStore.getUserDevices(event.senderId ?: "") if (event.senderId != null && olmContent?.senderKey != null) {
?.values markOlmSessionForUnwedging(event.senderId, olmContent.senderKey)
?.firstOrNull { it.identityKey() == olmContent?.senderKey } } else {
?.let { Timber.tag(loggerTag.value).d("Can't mark as wedge malformed")
markOlmSessionForUnwedging(event.senderId ?: "", it)
}
?: run {
Timber.i("## CRYPTO | internalDecryptEvent() : Failed to find sender crypto device for unwedging")
}
} }
} }
} }
@ -132,52 +136,90 @@ internal class EventDecryptor @Inject constructor(
} }
} }
// coroutineDispatchers.crypto scope private fun markOlmSessionForUnwedging(senderId: String, senderKey: String) {
private fun markOlmSessionForUnwedging(senderId: String, deviceInfo: CryptoDeviceInfo) { val info = WedgedDeviceInfo(senderId, senderKey)
val deviceKey = deviceInfo.identityKey() 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() val now = System.currentTimeMillis()
val toUnwedge = wedgedDevices.filter {
val lastForcedDate = lastNewSessionForcedDates[it] ?: 0
if (now - lastForcedDate < DefaultCryptoService.CRYPTO_MIN_FORCE_SESSION_PERIOD_MILLIS) { 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") 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 return
} }
Timber.tag(loggerTag.value).d("Unwedging, trying to create new session for ${toUnwedge.size} devices")
Timber.i("## CRYPTO | markOlmSessionForUnwedging from $senderId:${deviceInfo.deviceId}") toUnwedge
lastNewSessionForcedDates.setObject(senderId, deviceKey, now) .chunked(100) // safer to chunk if we ever have lots of wedged devices
.forEach { wedgedList ->
// offload this from crypto thread (?) val groupedByUserId = wedgedList.groupBy { it.userId }
cryptoCoroutineScope.launch(coroutineDispatchers.computation) { // lets download keys if needed
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}")
}
)
}
}
private suspend fun sendDummyToDevice(ensured: MXUsersDevicesMap<MXOlmSessionResult>, deviceInfo: CryptoDeviceInfo, senderId: String) {
Timber.i("## CRYPTO | markOlmSessionForUnwedging() : ensureOlmSessionsForDevicesAction isEmpty:${ensured.isEmpty}")
// 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<String, Any>("type" to EventType.DUMMY)
val encodedPayload = messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo))
val sendToDeviceMap = MXUsersDevicesMap<Any>()
sendToDeviceMap.setObject(senderId, deviceInfo.deviceId, encodedPayload)
Timber.i("## CRYPTO | markOlmSessionForUnwedging() : sending dummy to $senderId:${deviceInfo.deviceId}")
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap) deviceListManager.downloadKeys(groupedByUserId.keys.toList(), false)
}
// 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 { 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 dummy message on that session so the other side knows about it.
val payloadJson = mapOf(
"type" to EventType.DUMMY
)
val sendToDeviceMap = MXUsersDevicesMap<Any>()
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)
}
// now let's send that
val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap)
withContext(coroutineDispatchers.io) {
sendToDeviceTask.executeRetry(sendToDeviceParams, remainingRetry = SEND_TO_DEVICE_RETRY_COUNT) sendToDeviceTask.executeRetry(sendToDeviceParams, remainingRetry = SEND_TO_DEVICE_RETRY_COUNT)
}
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.e(failure, "## CRYPTO | markOlmSessionForUnwedging() : failed to send dummy to $senderId:${deviceInfo.deviceId}") deviceList.flatMap { it.value }.joinToString { it.shortDebugString() }.let {
Timber.tag(loggerTag.value).e(failure, "## Failed to unwedge devices: $it}")
}
}
} }
} }
} }

View File

@ -19,8 +19,10 @@ package org.matrix.android.sdk.internal.crypto
import android.util.LruCache import android.util.LruCache
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.extensions.tryOrNull 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.model.OlmInboundGroupSessionWrapper2
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
import timber.log.Timber import timber.log.Timber
@ -28,6 +30,13 @@ import java.util.Timer
import java.util.TimerTask import java.util.TimerTask
import javax.inject.Inject 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. * 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 * 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 val senderKey: String
) )
private val sessionCache = object : LruCache<CacheKey, OlmInboundGroupSessionWrapper2>(30) { private val sessionCache = object : LruCache<CacheKey, InboundGroupSessionHolder>(100) {
override fun entryRemoved(evicted: Boolean, key: CacheKey?, oldValue: OlmInboundGroupSessionWrapper2?, newValue: OlmInboundGroupSessionWrapper2?) { override fun entryRemoved(evicted: Boolean, key: CacheKey?, oldValue: InboundGroupSessionHolder?, newValue: InboundGroupSessionHolder?) {
if (evicted && oldValue != null) { if (oldValue != null) {
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { cryptoCoroutineScope.launch(coroutineDispatchers.crypto) {
Timber.v("## Inbound: entryRemoved ${oldValue.roomId}-${oldValue.senderKey}") Timber.tag(loggerTag.value).v("## Inbound: entryRemoved ${oldValue.wrapper.roomId}-${oldValue.wrapper.senderKey}")
store.storeInboundGroupSessions(listOf(oldValue)) store.storeInboundGroupSessions(listOf(oldValue).map { it.wrapper })
oldValue.wrapper.olmInboundGroupSession?.releaseSession()
} }
} }
} }
@ -59,27 +69,50 @@ internal class InboundGroupSessionStore @Inject constructor(
private val dirtySession = mutableListOf<OlmInboundGroupSessionWrapper2>() private val dirtySession = mutableListOf<OlmInboundGroupSessionWrapper2>()
@Synchronized @Synchronized
fun getInboundGroupSession(sessionId: String, senderKey: String): OlmInboundGroupSessionWrapper2? { fun clear() {
synchronized(sessionCache) { sessionCache.evictAll()
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)
} }
@Synchronized
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 @Synchronized
fun storeInBoundGroupSession(wrapper: OlmInboundGroupSessionWrapper2, sessionId: String, senderKey: String) { fun replaceGroupSession(old: InboundGroupSessionHolder, new: InboundGroupSessionHolder, sessionId: String, senderKey: String) {
Timber.v("## Inbound: getInboundGroupSession mark as dirty ${wrapper.roomId}-${wrapper.senderKey}") 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 // We want to batch this a bit for performances
dirtySession.add(wrapper) dirtySession.add(holder.wrapper)
if (sessionCache[CacheKey(sessionId, senderKey)] == null) { if (sessionCache[CacheKey(sessionId, senderKey)] == null) {
// first time seen, put it in memory cache while waiting for batch insert // 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 // 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() timerTask?.cancel()
@ -96,7 +129,7 @@ internal class InboundGroupSessionStore @Inject constructor(
val toSave = mutableListOf<OlmInboundGroupSessionWrapper2>().apply { addAll(dirtySession) } val toSave = mutableListOf<OlmInboundGroupSessionWrapper2>().apply { addAll(dirtySession) }
dirtySession.clear() dirtySession.clear()
cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { 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 { tryOrNull {
store.storeInboundGroupSessions(toSave) store.storeInboundGroupSessions(toSave)
} }

View File

@ -16,6 +16,11 @@
package org.matrix.android.sdk.internal.crypto 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.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.util.JSON_DICT_PARAMETERIZED_TYPE import org.matrix.android.sdk.api.util.JSON_DICT_PARAMETERIZED_TYPE
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
@ -40,6 +45,8 @@ import timber.log.Timber
import java.net.URLEncoder import java.net.URLEncoder
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("MXOlmDevice", LoggerTag.CRYPTO)
// The libolm wrapper. // The libolm wrapper.
@SessionScope @SessionScope
internal class MXOlmDevice @Inject constructor( internal class MXOlmDevice @Inject constructor(
@ -47,9 +54,12 @@ internal class MXOlmDevice @Inject constructor(
* The store where crypto data is saved. * The store where crypto data is saved.
*/ */
private val store: IMXCryptoStore, private val store: IMXCryptoStore,
private val olmSessionStore: OlmSessionStore,
private val inboundGroupSessionStore: InboundGroupSessionStore private val inboundGroupSessionStore: InboundGroupSessionStore
) { ) {
val mutex = Mutex()
/** /**
* @return the Curve25519 key for the account. * @return the Curve25519 key for the account.
*/ */
@ -93,26 +103,26 @@ internal class MXOlmDevice @Inject constructor(
try { try {
store.getOrCreateOlmAccount() store.getOrCreateOlmAccount()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "MXOlmDevice : cannot initialize olmAccount") Timber.tag(loggerTag.value).e(e, "MXOlmDevice : cannot initialize olmAccount")
} }
try { try {
olmUtility = OlmUtility() olmUtility = OlmUtility()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## MXOlmDevice : OlmUtility failed with error") Timber.tag(loggerTag.value).e(e, "## MXOlmDevice : OlmUtility failed with error")
olmUtility = null olmUtility = null
} }
try { try {
deviceCurve25519Key = store.getOlmAccount().identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY] deviceCurve25519Key = store.doWithOlmAccount { it.identityKeys()[OlmAccount.JSON_KEY_IDENTITY_KEY] }
} catch (e: Exception) { } 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 { 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) { } 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<String, Map<String, String>>? { fun getOneTimeKeys(): Map<String, Map<String, String>>? {
try { try {
return store.getOlmAccount().oneTimeKeys() return store.doWithOlmAccount { it.oneTimeKeys() }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## getOneTimeKeys() : failed") Timber.tag(loggerTag.value).e(e, "## getOneTimeKeys() : failed")
} }
return null return null
@ -133,7 +143,7 @@ internal class MXOlmDevice @Inject constructor(
* @return The maximum number of one-time keys the olm account can store. * @return The maximum number of one-time keys the olm account can store.
*/ */
fun getMaxNumberOfOneTimeKeys(): Long { fun getMaxNumberOfOneTimeKeys(): Long {
return store.getOlmAccount().maxOneTimeKeys() return store.doWithOlmAccount { it.maxOneTimeKeys() }
} }
/** /**
@ -143,9 +153,9 @@ internal class MXOlmDevice @Inject constructor(
*/ */
fun getFallbackKey(): MutableMap<String, MutableMap<String, String>>? { fun getFallbackKey(): MutableMap<String, MutableMap<String, String>>? {
try { try {
return store.getOlmAccount().fallbackKey() return store.doWithOlmAccount { it.fallbackKey() }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("## getFallbackKey() : failed") Timber.tag(loggerTag.value).e("## getFallbackKey() : failed")
} }
return null return null
} }
@ -158,12 +168,14 @@ internal class MXOlmDevice @Inject constructor(
fun generateFallbackKeyIfNeeded(): Boolean { fun generateFallbackKeyIfNeeded(): Boolean {
try { try {
if (!hasUnpublishedFallbackKey()) { if (!hasUnpublishedFallbackKey()) {
store.getOlmAccount().generateFallbackKey() store.doWithOlmAccount {
it.generateFallbackKey()
store.saveOlmAccount() store.saveOlmAccount()
}
return true return true
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("## generateFallbackKey() : failed") Timber.tag(loggerTag.value).e("## generateFallbackKey() : failed")
} }
return false return false
} }
@ -174,10 +186,12 @@ internal class MXOlmDevice @Inject constructor(
fun forgetFallbackKey() { fun forgetFallbackKey() {
try { try {
store.getOlmAccount().forgetFallbackKey() store.doWithOlmAccount {
it.forgetFallbackKey()
store.saveOlmAccount() store.saveOlmAccount()
}
} catch (e: Exception) { } 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() it.groupSession.releaseSession()
} }
outboundGroupSessionCache.clear() outboundGroupSessionCache.clear()
inboundGroupSessionStore.clear()
olmSessionStore.clear()
} }
/** /**
@ -200,9 +216,9 @@ internal class MXOlmDevice @Inject constructor(
*/ */
fun signMessage(message: String): String? { fun signMessage(message: String): String? {
try { try {
return store.getOlmAccount().signMessage(message) return store.doWithOlmAccount { it.signMessage(message) }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## signMessage() : failed") Timber.tag(loggerTag.value).e(e, "## signMessage() : failed")
} }
return null return null
@ -213,10 +229,12 @@ internal class MXOlmDevice @Inject constructor(
*/ */
fun markKeysAsPublished() { fun markKeysAsPublished() {
try { try {
store.getOlmAccount().markOneTimeKeysAsPublished() store.doWithOlmAccount {
it.markOneTimeKeysAsPublished()
store.saveOlmAccount() store.saveOlmAccount()
}
} catch (e: Exception) { } 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) { fun generateOneTimeKeys(numKeys: Int) {
try { try {
store.getOlmAccount().generateOneTimeKeys(numKeys) store.doWithOlmAccount {
it.generateOneTimeKeys(numKeys)
store.saveOlmAccount() store.saveOlmAccount()
}
} catch (e: Exception) { } 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. * @return the session id for the outbound session.
*/ */
fun createOutboundSession(theirIdentityKey: String, theirOneTimeKey: String): String? { 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 var olmSession: OlmSession? = null
try { try {
olmSession = OlmSession() olmSession = OlmSession()
olmSession.initOutboundSession(store.getOlmAccount(), theirIdentityKey, theirOneTimeKey) store.doWithOlmAccount { olmAccount ->
olmSession.initOutboundSession(olmAccount, theirIdentityKey, theirOneTimeKey)
}
val olmSessionWrapper = OlmSessionWrapper(olmSession, 0) val olmSessionWrapper = OlmSessionWrapper(olmSession, 0)
@ -257,14 +279,14 @@ internal class MXOlmDevice @Inject constructor(
// this session // this session
olmSessionWrapper.onMessageReceived() olmSessionWrapper.onMessageReceived()
store.storeSession(olmSessionWrapper, theirIdentityKey) olmSessionStore.storeSession(olmSessionWrapper, theirIdentityKey)
val sessionIdentifier = olmSession.sessionIdentifier() val sessionIdentifier = olmSession.sessionIdentifier()
Timber.v("## createOutboundSession() ; olmSession.sessionIdentifier: $sessionIdentifier") Timber.tag(loggerTag.value).v("## createOutboundSession() ; olmSession.sessionIdentifier: $sessionIdentifier")
return sessionIdentifier return sessionIdentifier
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## createOutboundSession() failed") Timber.tag(loggerTag.value).e(e, "## createOutboundSession() failed")
olmSession?.releaseSession() 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. * @return {{payload: string, session_id: string}} decrypted payload, and session id of new session.
*/ */
fun createInboundSession(theirDeviceIdentityKey: String, messageType: Int, ciphertext: String): Map<String, String>? { fun createInboundSession(theirDeviceIdentityKey: String, messageType: Int, ciphertext: String): Map<String, String>? {
Timber.v("## createInboundSession() : theirIdentityKey: $theirDeviceIdentityKey") Timber.tag(loggerTag.value).d("## createInboundSession() : theirIdentityKey: $theirDeviceIdentityKey")
var olmSession: OlmSession? = null var olmSession: OlmSession? = null
try { try {
try { try {
olmSession = OlmSession() olmSession = OlmSession()
olmSession.initInboundSessionFrom(store.getOlmAccount(), theirDeviceIdentityKey, ciphertext) store.doWithOlmAccount { olmAccount ->
olmSession.initInboundSessionFrom(olmAccount, theirDeviceIdentityKey, ciphertext)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## createInboundSession() : the session creation failed") Timber.tag(loggerTag.value).e(e, "## createInboundSession() : the session creation failed")
return null return null
} }
Timber.v("## createInboundSession() : sessionId: ${olmSession.sessionIdentifier()}") Timber.tag(loggerTag.value).v("## createInboundSession() : sessionId: ${olmSession.sessionIdentifier()}")
try { try {
store.getOlmAccount().removeOneTimeKeys(olmSession) store.doWithOlmAccount { olmAccount ->
olmAccount.removeOneTimeKeys(olmSession)
store.saveOlmAccount() store.saveOlmAccount()
}
} catch (e: Exception) { } 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 { try {
val sha256 = olmUtility!!.sha256(URLEncoder.encode(ciphertext, "utf-8")) 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) { } 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() 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 // This counts as a received message: set last received message time to now
olmSessionWrapper.onMessageReceived() olmSessionWrapper.onMessageReceived()
store.storeSession(olmSessionWrapper, theirDeviceIdentityKey) olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## createInboundSession() : decryptMessage failed") Timber.tag(loggerTag.value).e(e, "## createInboundSession() : decryptMessage failed")
} }
val res = HashMap<String, String>() val res = HashMap<String, String>()
@ -343,7 +369,7 @@ internal class MXOlmDevice @Inject constructor(
return res return res
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## createInboundSession() : OlmSession creation failed") Timber.tag(loggerTag.value).e(e, "## createInboundSession() : OlmSession creation failed")
olmSession?.releaseSession() olmSession?.releaseSession()
} }
@ -357,8 +383,8 @@ internal class MXOlmDevice @Inject constructor(
* @param theirDeviceIdentityKey the Curve25519 identity key for the remote device. * @param theirDeviceIdentityKey the Curve25519 identity key for the remote device.
* @return a list of known session ids for the device. * @return a list of known session ids for the device.
*/ */
fun getSessionIds(theirDeviceIdentityKey: String): List<String>? { fun getSessionIds(theirDeviceIdentityKey: String): List<String> {
return store.getDeviceSessionIds(theirDeviceIdentityKey) return olmSessionStore.getDeviceSessionIds(theirDeviceIdentityKey)
} }
/** /**
@ -368,7 +394,7 @@ internal class MXOlmDevice @Inject constructor(
* @return the session id, or null if no established session. * @return the session id, or null if no established session.
*/ */
fun getSessionId(theirDeviceIdentityKey: String): String? { 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 * @param payloadString the payload to be encrypted and sent
* @return the cipher text * @return the cipher text
*/ */
fun encryptMessage(theirDeviceIdentityKey: String, sessionId: String, payloadString: String): Map<String, Any>? { suspend fun encryptMessage(theirDeviceIdentityKey: String, sessionId: String, payloadString: String): Map<String, Any>? {
var res: MutableMap<String, Any>? = null
val olmMessage: OlmMessage
val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId) val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId)
if (olmSessionWrapper != null) { if (olmSessionWrapper != null) {
try { try {
Timber.v("## encryptMessage() : olmSession.sessionIdentifier: $sessionId") Timber.tag(loggerTag.value).v("## encryptMessage() : olmSession.sessionIdentifier: $sessionId")
// Timber.v("## encryptMessage() : payloadString: " + payloadString);
olmMessage = olmSessionWrapper.olmSession.encryptMessage(payloadString) val olmMessage = olmSessionWrapper.mutex.withLock {
store.storeSession(olmSessionWrapper, theirDeviceIdentityKey) olmSessionWrapper.olmSession.encryptMessage(payloadString)
res = HashMap() }
return mapOf(
res["body"] = olmMessage.mCipherText "body" to olmMessage.mCipherText,
res["type"] = olmMessage.mType "type" to olmMessage.mType,
} catch (e: Exception) { ).also {
Timber.e(e, "## encryptMessage() : failed") 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 { } 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. * @param sessionId the id of the active session.
* @return the decrypted payload. * @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 var payloadString: String? = null
val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId) val olmSessionWrapper = getSessionForDevice(theirDeviceIdentityKey, sessionId)
@ -424,14 +451,14 @@ internal class MXOlmDevice @Inject constructor(
olmMessage.mCipherText = ciphertext olmMessage.mCipherText = ciphertext
olmMessage.mType = messageType.toLong() olmMessage.mType = messageType.toLong()
try { payloadString =
payloadString = olmSessionWrapper.olmSession.decryptMessage(olmMessage) olmSessionWrapper.mutex.withLock {
olmSessionWrapper.olmSession.decryptMessage(olmMessage).also {
olmSessionWrapper.onMessageReceived() olmSessionWrapper.onMessageReceived()
store.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
} catch (e: Exception) {
Timber.e(e, "## decryptMessage() : decryptMessage failed")
} }
} }
olmSessionStore.storeSession(olmSessionWrapper, theirDeviceIdentityKey)
}
return payloadString return payloadString
} }
@ -469,7 +496,7 @@ internal class MXOlmDevice @Inject constructor(
store.storeCurrentOutboundGroupSessionForRoom(roomId, session) store.storeCurrentOutboundGroupSessionForRoom(roomId, session)
return session.sessionIdentifier() return session.sessionIdentifier()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "createOutboundGroupSession") Timber.tag(loggerTag.value).e(e, "createOutboundGroupSession")
session?.releaseSession() session?.releaseSession()
} }
@ -521,7 +548,7 @@ internal class MXOlmDevice @Inject constructor(
try { try {
return outboundGroupSessionCache[sessionId]!!.groupSession.sessionKey() return outboundGroupSessionCache[sessionId]!!.groupSession.sessionKey()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## getSessionKey() : failed") Timber.tag(loggerTag.value).e(e, "## getSessionKey() : failed")
} }
} }
return null return null
@ -550,8 +577,8 @@ internal class MXOlmDevice @Inject constructor(
if (sessionId.isNotEmpty() && payloadString.isNotEmpty()) { if (sessionId.isNotEmpty() && payloadString.isNotEmpty()) {
try { try {
return outboundGroupSessionCache[sessionId]!!.groupSession.encryptMessage(payloadString) return outboundGroupSessionCache[sessionId]!!.groupSession.encryptMessage(payloadString)
} catch (e: Exception) { } catch (e: Throwable) {
Timber.e(e, "## encryptGroupMessage() : failed") Timber.tag(loggerTag.value).e(e, "## encryptGroupMessage() : failed")
} }
} }
return null return null
@ -578,52 +605,64 @@ internal class MXOlmDevice @Inject constructor(
forwardingCurve25519KeyChain: List<String>, forwardingCurve25519KeyChain: List<String>,
keysClaimed: Map<String, String>, keysClaimed: Map<String, String>,
exportFormat: Boolean): Boolean { exportFormat: Boolean): Boolean {
val session = OlmInboundGroupSessionWrapper2(sessionKey, exportFormat) val candidateSession = OlmInboundGroupSessionWrapper2(sessionKey, exportFormat)
runCatching { getInboundGroupSession(sessionId, senderKey, roomId) } val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) }
.fold( val existingSession = existingSessionHolder?.wrapper
{ // If we have an existing one we should check if the new one is not better
// If we already have this session, consider updating it if (existingSession != null) {
Timber.e("## addInboundGroupSession() : Update for megolm session $senderKey/$sessionId") Timber.tag(loggerTag.value).d("## addInboundGroupSession() check if known session is better than candidate session")
try {
val existingFirstKnown = it.firstKnownIndex!! val existingFirstKnown = existingSession.firstKnownIndex ?: return false.also {
val newKnownFirstIndex = session.firstKnownIndex // 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 our existing session is better we keep it
if (newKnownFirstIndex != null && existingFirstKnown <= newKnownFirstIndex) { if (newKnownFirstIndex != null && existingFirstKnown <= newKnownFirstIndex) {
session.olmInboundGroupSession?.releaseSession() 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 return false
} }
},
{
// Nothing to do in case of error
} }
)
// sanity check Timber.tag(loggerTag.value).d("## addInboundGroupSession() : Candidate session should be added $senderKey/$sessionId")
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 <null>")
return false return false
} }
try { try {
if (session.olmInboundGroupSession!!.sessionIdentifier() != sessionId) { if (candidateOlmInboundSession.sessionIdentifier() != sessionId) {
Timber.e("## addInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") Timber.tag(loggerTag.value).e("## addInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey")
session.olmInboundGroupSession!!.releaseSession() candidateOlmInboundSession.releaseSession()
return false return false
} }
} catch (e: Exception) { } catch (e: Throwable) {
session.olmInboundGroupSession?.releaseSession() candidateOlmInboundSession.releaseSession()
Timber.e(e, "## addInboundGroupSession : sessionIdentifier() failed") Timber.tag(loggerTag.value).e(e, "## addInboundGroupSession : sessionIdentifier() failed")
return false return false
} }
session.senderKey = senderKey candidateSession.senderKey = senderKey
session.roomId = roomId candidateSession.roomId = roomId
session.keysClaimed = keysClaimed candidateSession.keysClaimed = keysClaimed
session.forwardingCurve25519KeyChain = forwardingCurve25519KeyChain candidateSession.forwardingCurve25519KeyChain = forwardingCurve25519KeyChain
inboundGroupSessionStore.storeInBoundGroupSession(session, sessionId, senderKey) if (existingSession != null) {
// store.storeInboundGroupSessions(listOf(session)) inboundGroupSessionStore.replaceGroupSession(existingSessionHolder, InboundGroupSessionHolder(candidateSession), sessionId, senderKey)
} else {
inboundGroupSessionStore.storeInBoundGroupSession(InboundGroupSessionHolder(candidateSession), sessionId, senderKey)
}
return true return true
} }
@ -638,57 +677,70 @@ internal class MXOlmDevice @Inject constructor(
val sessions = ArrayList<OlmInboundGroupSessionWrapper2>(megolmSessionsData.size) val sessions = ArrayList<OlmInboundGroupSessionWrapper2>(megolmSessionsData.size)
for (megolmSessionData in megolmSessionsData) { for (megolmSessionData in megolmSessionsData) {
val sessionId = megolmSessionData.sessionId val sessionId = megolmSessionData.sessionId ?: continue
val senderKey = megolmSessionData.senderKey val senderKey = megolmSessionData.senderKey ?: continue
val roomId = megolmSessionData.roomId val roomId = megolmSessionData.roomId
var session: OlmInboundGroupSessionWrapper2? = null var candidateSessionToImport: OlmInboundGroupSessionWrapper2? = null
try { try {
session = OlmInboundGroupSessionWrapper2(megolmSessionData) candidateSessionToImport = OlmInboundGroupSessionWrapper2(megolmSessionData)
} catch (e: Exception) { } 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 // sanity check
if (session?.olmInboundGroupSession == null) { if (candidateSessionToImport?.olmInboundGroupSession == null) {
Timber.e("## importInboundGroupSession : invalid session") Timber.tag(loggerTag.value).e("## importInboundGroupSession : invalid session")
continue continue
} }
val candidateOlmInboundGroupSession = candidateSessionToImport.olmInboundGroupSession
try { try {
if (session.olmInboundGroupSession?.sessionIdentifier() != sessionId) { if (candidateOlmInboundGroupSession?.sessionIdentifier() != sessionId) {
Timber.e("## importInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") Timber.tag(loggerTag.value).e("## importInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey")
if (session.olmInboundGroupSession != null) session.olmInboundGroupSession!!.releaseSession() candidateOlmInboundGroupSession?.releaseSession()
continue continue
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## importInboundGroupSession : sessionIdentifier() failed") Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession : sessionIdentifier() failed")
session.olmInboundGroupSession!!.releaseSession() candidateOlmInboundGroupSession?.releaseSession()
continue continue
} }
runCatching { getInboundGroupSession(sessionId, senderKey, roomId) } val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) }
.fold( val existingSession = existingSessionHolder?.wrapper
{
// If we already have this session, consider updating it
Timber.e("## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId")
// For now we just ignore updates. TODO: implement something here if (existingSession == null) {
if (it.firstKnownIndex!! <= session.firstKnownIndex!!) {
// Ignore this, keep existing
session.olmInboundGroupSession!!.releaseSession()
} else {
sessions.add(session)
}
Unit
},
{
// Session does not already exist, add it // Session does not already exist, add it
sessions.add(session) 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) store.storeInboundGroupSessions(sessions)
@ -696,18 +748,6 @@ internal class MXOlmDevice @Inject constructor(
return sessions 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. * 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. * @return the decrypting result. Nil if the sessionId is unknown.
*/ */
@Throws(MXCryptoError::class) @Throws(MXCryptoError::class)
fun decryptGroupMessage(body: String, suspend fun decryptGroupMessage(body: String,
roomId: String, roomId: String,
timeline: String?, timeline: String?,
sessionId: String, sessionId: String,
senderKey: String): OlmDecryptionResult { senderKey: String): OlmDecryptionResult {
val session = getInboundGroupSession(sessionId, senderKey, roomId) 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 // Check that the room id matches the original one for the session. This stops
// the HS pretending a message was targeting a different room. // the HS pretending a message was targeting a different room.
if (roomId == session.roomId) { if (roomId == wrapper.roomId) {
val decryptResult = try { val decryptResult = try {
session.olmInboundGroupSession!!.decryptMessage(body) sessionHolder.mutex.withLock {
inboundGroupSession.decryptMessage(body)
}
} catch (e: OlmException) { } catch (e: OlmException) {
Timber.e(e, "## decryptGroupMessage () : decryptMessage failed") Timber.tag(loggerTag.value).e(e, "## decryptGroupMessage () : decryptMessage failed")
throw MXCryptoError.OlmError(e) throw MXCryptoError.OlmError(e)
} }
@ -742,32 +787,32 @@ internal class MXOlmDevice @Inject constructor(
if (timelineSet.contains(messageIndexKey)) { if (timelineSet.contains(messageIndexKey)) {
val reason = String.format(MXCryptoError.DUPLICATE_MESSAGE_INDEX_REASON, decryptResult.mIndex) 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) throw MXCryptoError.Base(MXCryptoError.ErrorType.DUPLICATED_MESSAGE_INDEX, reason)
} }
timelineSet.add(messageIndexKey) timelineSet.add(messageIndexKey)
} }
inboundGroupSessionStore.storeInBoundGroupSession(session, sessionId, senderKey) inboundGroupSessionStore.storeInBoundGroupSession(sessionHolder, sessionId, senderKey)
val payload = try { val payload = try {
val adapter = MoshiProvider.providesMoshi().adapter<JsonDict>(JSON_DICT_PARAMETERIZED_TYPE) val adapter = MoshiProvider.providesMoshi().adapter<JsonDict>(JSON_DICT_PARAMETERIZED_TYPE)
val payloadString = convertFromUTF8(decryptResult.mDecryptedMessage) val payloadString = convertFromUTF8(decryptResult.mDecryptedMessage)
adapter.fromJson(payloadString) adapter.fromJson(payloadString)
} catch (e: Exception) { } 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) throw MXCryptoError.Base(MXCryptoError.ErrorType.BAD_DECRYPTED_FORMAT, MXCryptoError.BAD_DECRYPTED_FORMAT_TEXT_REASON)
} }
return OlmDecryptionResult( return OlmDecryptionResult(
payload, payload,
session.keysClaimed, wrapper.keysClaimed,
senderKey, senderKey,
session.forwardingCurve25519KeyChain wrapper.forwardingCurve25519KeyChain
) )
} else { } else {
val reason = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, session.roomId) val reason = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, roomId, wrapper.roomId)
Timber.e("## decryptGroupMessage() : $reason") Timber.tag(loggerTag.value).e("## decryptGroupMessage() : $reason")
throw MXCryptoError.Base(MXCryptoError.ErrorType.INBOUND_SESSION_MISMATCH_ROOM_ID, 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? { private fun getSessionForDevice(theirDeviceIdentityKey: String, sessionId: String): OlmSessionWrapper? {
// sanity check // sanity check
return if (theirDeviceIdentityKey.isEmpty() || sessionId.isEmpty()) null else { 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. * @param senderKey the base64-encoded curve25519 key of the sender.
* @return the inbound group session. * @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()) { if (sessionId.isNullOrBlank() || senderKey.isNullOrBlank()) {
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_SENDER_KEY, MXCryptoError.ERROR_MISSING_PROPERTY_REASON) 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) { if (session != null) {
// Check that the room id matches the original one for the session. This stops // Check that the room id matches the original one for the session. This stops
// the HS pretending a message was targeting a different room. // the HS pretending a message was targeting a different room.
if (roomId != session.roomId) { if (roomId != session.roomId) {
val errorDescription = String.format(MXCryptoError.INBOUND_SESSION_MISMATCH_ROOM_ID_REASON, 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) throw MXCryptoError.Base(MXCryptoError.ErrorType.INBOUND_SESSION_MISMATCH_ROOM_ID, errorDescription)
} else { } else {
return session return holder
} }
} else { } 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) 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 { fun hasInboundSessionKeys(roomId: String, senderKey: String, sessionId: String): Boolean {
return runCatching { getInboundGroupSession(sessionId, senderKey, roomId) }.isSuccess return runCatching { getInboundGroupSession(sessionId, senderKey, roomId) }.isSuccess
} }
@VisibleForTesting
fun clearOlmSessionCache() {
olmSessionStore.clear()
}
} }

View File

@ -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<String, MutableList<OlmSessionWrapper>>()
/**
* 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<String> {
// 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()
}
}
}
}

View File

@ -16,14 +16,18 @@
package org.matrix.android.sdk.internal.crypto.actions 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.api.logger.LoggerTag
import org.matrix.android.sdk.internal.crypto.MXOlmDevice 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.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.MXKey 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.MXOlmSessionResult
import org.matrix.android.sdk.internal.crypto.model.MXUsersDevicesMap 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.crypto.tasks.ClaimOneTimeKeysForUsersDeviceTask
import org.matrix.android.sdk.internal.session.SessionScope
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -31,91 +35,91 @@ private const val ONE_TIME_KEYS_RETRY_COUNT = 3
private val loggerTag = LoggerTag("EnsureOlmSessionsForDevicesAction", LoggerTag.CRYPTO) private val loggerTag = LoggerTag("EnsureOlmSessionsForDevicesAction", LoggerTag.CRYPTO)
@SessionScope
internal class EnsureOlmSessionsForDevicesAction @Inject constructor( internal class EnsureOlmSessionsForDevicesAction @Inject constructor(
private val olmDevice: MXOlmDevice, private val olmDevice: MXOlmDevice,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val oneTimeKeysForUsersDeviceTask: ClaimOneTimeKeysForUsersDeviceTask) { 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<String, List<CryptoDeviceInfo>>, force: Boolean = false): MXUsersDevicesMap<MXOlmSessionResult> { suspend fun handle(devicesByUser: Map<String, List<CryptoDeviceInfo>>, force: Boolean = false): MXUsersDevicesMap<MXOlmSessionResult> {
val devicesWithoutSession = ArrayList<CryptoDeviceInfo>() ensureMutex.withLock {
val results = MXUsersDevicesMap<MXOlmSessionResult>() val results = MXUsersDevicesMap<MXOlmSessionResult>()
val deviceList = devicesByUser.flatMap { it.value }
for ((userId, deviceList) in devicesByUser) { Timber.tag(loggerTag.value)
for (deviceInfo in deviceList) { .d("ensure olm forced:$force for ${deviceList.joinToString { it.shortDebugString() }}")
val devicesToCreateSessionWith = mutableListOf<CryptoDeviceInfo>()
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 deviceId = deviceInfo.deviceId
val key = deviceInfo.identityKey() val userId = deviceInfo.userId
if (key == null) { val key = deviceInfo.identityKey() ?: return@forEach Unit.also {
Timber.w("## CRYPTO | Ignoring device (${deviceInfo.userId}|$deviceId) without identity key") Timber.tag(loggerTag.value).w("Ignoring device ${deviceInfo.shortDebugString()} without identity key")
continue
} }
// is there a session that as been already used?
val sessionId = olmDevice.getSessionId(key) val sessionId = olmDevice.getSessionId(key)
if (sessionId.isNullOrEmpty()) {
if (sessionId.isNullOrEmpty() || force) { Timber.tag(loggerTag.value).d("Found no existing olm session ${deviceInfo.shortDebugString()} add to claim list")
Timber.tag(loggerTag.value).d("Found no existing olm session (${deviceInfo.userId}|$deviceId) (force=$force)") devicesToCreateSessionWith.add(deviceInfo)
devicesWithoutSession.add(deviceInfo)
} else { } else {
Timber.tag(loggerTag.value).d("using olm session $sessionId for (${deviceInfo.userId}|$deviceId)") Timber.tag(loggerTag.value).d("using olm session $sessionId for (${deviceInfo.userId}|$deviceId)")
}
val olmSessionResult = MXOlmSessionResult(deviceInfo, sessionId) val olmSessionResult = MXOlmSessionResult(deviceInfo, sessionId)
results.setObject(userId, deviceId, olmSessionResult) results.setObject(userId, deviceId, olmSessionResult)
} }
} }
}
Timber.tag(loggerTag.value).d("Devices without olm session (count:${devicesWithoutSession.size}) :" + if (devicesToCreateSessionWith.isEmpty()) {
" ${devicesWithoutSession.joinToString { "${it.userId}|${it.deviceId}" }}") // no session to create
if (devicesWithoutSession.size == 0) {
return results return results
} }
val usersDevicesToClaim = MXUsersDevicesMap<String>().apply {
// Prepare the request for claiming one-time keys devicesToCreateSessionWith.forEach {
val usersDevicesToClaim = MXUsersDevicesMap<String>() setObject(it.userId, it.deviceId, MXKey.KEY_SIGNED_CURVE_25519_TYPE)
}
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 // Let's now claim one time keys
// 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 claimParams = ClaimOneTimeKeysForUsersDeviceTask.Params(usersDevicesToClaim)
val oneTimeKeys = oneTimeKeysForUsersDeviceTask.executeRetry(claimParams, remainingRetry = ONE_TIME_KEYS_RETRY_COUNT) val oneTimeKeys = withContext(coroutineDispatchers.io) {
Timber.tag(loggerTag.value).v("claimOneTimeKeysForUsersDevices() : keysClaimResponse.oneTimeKeys: $oneTimeKeys") oneTimeKeysForUsersDeviceTask.executeRetry(claimParams, ONE_TIME_KEYS_RETRY_COUNT)
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
} }
// 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) { if (oneTimeKey == null) {
Timber.tag(loggerTag.value).d("No one time key for $userId|$deviceId") Timber.tag(loggerTag.value).d("No otk for ${deviceInfo.shortDebugString()}")
continue } else if (oneTimeKey.type != MXKey.KEY_SIGNED_CURVE_25519_TYPE) {
} Timber.tag(loggerTag.value).d("Bad otk type (${oneTimeKey.type}) for ${deviceInfo.shortDebugString()}")
// Update the result for this device in results } else {
olmSessionResult?.sessionId = verifyKeyAndStartSession(oneTimeKey, userId, deviceInfo) 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? { private fun verifyKeyAndStartSession(oneTimeKey: MXKey, userId: String, deviceInfo: CryptoDeviceInfo): String? {
var sessionId: String? = null var sessionId: String? = null

View File

@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.crypto.actions 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.api.session.events.model.Content
import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_OLM import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_OLM
import org.matrix.android.sdk.internal.crypto.MXOlmDevice 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("MessageEncrypter", LoggerTag.CRYPTO)
internal class MessageEncrypter @Inject constructor( internal class MessageEncrypter @Inject constructor(
@UserId @UserId
private val userId: String, private val userId: String,
@ -42,7 +45,7 @@ internal class MessageEncrypter @Inject constructor(
* @param deviceInfos list of device infos to encrypt for. * @param deviceInfos list of device infos to encrypt for.
* @return the content for an m.room.encrypted event. * @return the content for an m.room.encrypted event.
*/ */
fun encryptMessage(payloadFields: Content, deviceInfos: List<CryptoDeviceInfo>): EncryptedMessage { suspend fun encryptMessage(payloadFields: Content, deviceInfos: List<CryptoDeviceInfo>): EncryptedMessage {
val deviceInfoParticipantKey = deviceInfos.associateBy { it.identityKey()!! } val deviceInfoParticipantKey = deviceInfos.associateBy { it.identityKey()!! }
val payloadJson = payloadFields.toMutableMap() val payloadJson = payloadFields.toMutableMap()
@ -66,7 +69,7 @@ internal class MessageEncrypter @Inject constructor(
val sessionId = olmDevice.getSessionId(deviceKey) val sessionId = olmDevice.getSessionId(deviceKey)
if (!sessionId.isNullOrEmpty()) { 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"] = deviceInfo.userId
payloadJson["recipient_keys"] = mapOf("ed25519" to deviceInfo.fingerprint()!!) payloadJson["recipient_keys"] = mapOf("ed25519" to deviceInfo.fingerprint()!!)

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