Merge branch 'develop' into feature/ons/fix_turkish_translations

This commit is contained in:
ganfra 2022-02-08 18:06:10 +01:00
commit 33648963c4
380 changed files with 9803 additions and 2702 deletions

View File

@ -1,6 +1,6 @@
name: Release checklist
description: Checklist for each release. This template is only for the core team.
title: "[Release] Element Android v"
title: "[Release] Element Android v"
labels: [🚀 Release]
assignees:
- bmarty
@ -10,7 +10,7 @@ body:
id: checklist
attributes:
label: Release checklist
description: For the template example, we are releasing the version 1.1.10. Replace 1.1.10 with the version in the issue body.
description: For the template example, we are releasing the version 1.2.3. Replace 1.2.3 with the version in the issue body.
placeholder: |
If you are reading this, you have deleted the content of the release template: undo the deletion or start again.
value: |
@ -22,35 +22,41 @@ body:
### Do the release
- [ ] Create release with gitflow, branch name `release/1.1.10`
- [ ] Create release with gitflow, branch name `release/1.2.3`
- [ ] Check the crashes from the PlayStore
- [ ] Check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/1.1.10-dev
- [ ] Check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/1.2.3-dev
- [ ] Run the integration test, and especially `UiAllScreensSanityTest.allScreensTest()`
- [ ] Create an account on matrix.org
- [ ] Run towncrier: `towncrier --version v1.1.10 --draft` (remove `--draft` do write the file CHANGES.md)
- [ ] Create an account on matrix.org and do some smoke tests that the sanity test does not cover like: 1-1 call, 1-1 video call, Jitsi call for instance
- [ ] Run towncrier: `towncrier --version v1.2.3 --draft` (remove `--draft` do write the file CHANGES.md)
- [ ] Check that the folder `changelog.d` is empty. It can happen that some remaining files stay here
- [ ] Check the file CHANGES.md consistency. It's possible to reorder items (most important changes first) or change their section if relevant. Also an opportunity to fix some typo, or rewrite things
- [ ] Add file for fastlane under ./fastlane/metadata/android/en-US/changelogs
- [ ] Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes.
- [ ] Finish release with gitflow, delete the draft PR
- [ ] Push `main` and the new tag `v1.1.10` to origin
- [ ] (optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes.
- [ ] Finish release with gitflow, delete the draft PR (if created)
- [ ] Push `main` and the new tag `v1.2.3` to origin
- [ ] Checkout `develop`
- [ ] Increase version in `./vector/build.gradle`
- [ ] Increase version (versionPatch + 2) in `./vector/build.gradle`
- [ ] Change the value of SDK_VERSION in the file `./matrix-sdk-android/build.gradle`
- [ ] Commit and push `develop`
- [ ] Wait for [Buildkite](https://buildkite.com/matrix-dot-org/element-android/builds?branch=main) to build the `main` branch.
- [ ] Run the script `~/scripts/releaseElement.sh`. It will download the APKs from Buildkite check them and sign them.
- [ ] Install the APK on your phone to check that the upgrade went well (no init sync, etc.)
- [ ] Create the release on gitHub [from the tag](https://github.com/vector-im/element-android/tags), copy paste the block from the file CHANGES.md
- [ ] Add the 4 signed APKs to the GitHub release
- [ ] Ping the Android Internal room
### Once tested and validated internally
- [ ] Create a new beta release on the GooglePlay console and upload the 4 signed Apks.
- [ ] Check that the version codes are correct
- [ ] Copy the fastlane change to the GooglePlay console in the section en-GB.
- [ ] Push to beta release to 100% of the users
- [ ] Create the release on gitHub [from the tag](https://github.com/vector-im/element-android/tags), copy paste the block from the file CHANGES.md
- [ ] Add the 4 signed APKs to the GitHub release
- [ ] Ping the Android Internal room
- [ ] Add an entry in the internal diary
- [ ] Notify the F-Droid team so that they can schedule the publication on F-Droid
### Once Live on PlayStore
- [ ] Ping the Android public room and update its topic
- [ ] Add an entry in the internal diary
### After at least 2 days
@ -62,6 +68,8 @@ body:
### Android SDK2
The SDK2 and the sample app are released only when Element has been pushed to production.
- [ ] Checkout the `main` branch on Element Android project
#### On the SDK2 project

View File

@ -180,6 +180,7 @@ jobs:
body="$(cat ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml | grep "<testsuite" | sed "s@.*tests=\(.*\)time=.*@\1@")"
echo "::set-output name=permalink::passed=$body"
- name: Find Comment
if: github.event_name == 'pull_request'
uses: peter-evans/find-comment@v1
id: fc
with:
@ -187,6 +188,7 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: Integration Tests Results
- name: Publish results to PR
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v1
with:
comment-id: ${{ steps.fc.outputs.comment-id }}

View File

@ -54,7 +54,7 @@ jobs:
echo "::set-output name=body::$body"
fi
- name: Find Comment
if: always()
if: always() && github.event_name == 'pull_request'
uses: peter-evans/find-comment@v1
id: fc
with:
@ -62,7 +62,7 @@ jobs:
comment-author: 'github-actions[bot]'
body-includes: Ktlint Results
- name: Add comment if needed
if: always() && steps.ktlint-results.outputs.add_comment == 'true'
if: always() && github.event_name == 'pull_request' && steps.ktlint-results.outputs.add_comment == 'true'
uses: peter-evans/create-or-update-comment@v1
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
@ -73,7 +73,7 @@ jobs:
${{ steps.ktlint-results.outputs.body }}
edit-mode: replace
- name: Delete comment if needed
if: always() && steps.fc.outputs.comment-id != '' && steps.ktlint-results.outputs.add_comment == 'false'
if: always() && github.event_name == 'pull_request' && steps.fc.outputs.comment-id != '' && steps.ktlint-results.outputs.add_comment == 'false'
uses: actions/github-script@v3
with:
script: |

View File

@ -202,3 +202,53 @@ jobs:
env:
PROJECT_ID: "PN_kwDOAM0swc3m-g"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
move_ftue_issues:
name: Z-FTUE to Mobile FTUE board
runs-on: ubuntu-latest
# Skip in forks
if: >
github.repository == 'vector-im/element-android' &&
contains(github.event.issue.labels.*.name, 'Z-FTUE')
steps:
- uses: octokit/graphql-action@v2.x
with:
headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: |
mutation add_to_project($projectid:ID!,$contentid:ID!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) {
projectNextItem {
id
}
}
}
projectid: ${{ env.PROJECT_ID }}
contentid: ${{ github.event.issue.node_id }}
env:
PROJECT_ID: "PN_kwDOAM0swc4AAqVx"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
move_WTF_issues:
name: Z-WTF to WTF board
runs-on: ubuntu-latest
# Skip in forks
if: >
github.repository == 'vector-im/element-android' &&
contains(github.event.issue.labels.*.name, 'Z-WTF')
steps:
- uses: octokit/graphql-action@v2.x
with:
headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: |
mutation add_to_project($projectid:ID!,$contentid:ID!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) {
projectNextItem {
id
}
}
}
projectid: ${{ env.PROJECT_ID }}
contentid: ${{ github.event.issue.node_id }}
env:
PROJECT_ID: "PN_kwDOAM0swc4AArk0"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

View File

@ -1,3 +1,23 @@
Changes in Element v1.3.18 (2022-02-03)
=======================================
Bugfixes 🐛
----------
- Avoid deleting root event of CurrentState on gappy sync. In order to restore lost Events an initial sync may be triggered. ([#5137](https://github.com/vector-im/element-android/issues/5137))
Changes in Element v1.3.17 (2022-01-31)
=======================================
Bugfixes 🐛
----------
- Display static map images in the timeline and improve Location sharing feature ([#5084](https://github.com/vector-im/element-android/issues/5084))
- Show the legal mention of mapbox when sharing location ([#5062](https://github.com/vector-im/element-android/issues/5062))
- Poll cannot end in some unencrypted rooms ([#5067](https://github.com/vector-im/element-android/issues/5067))
- Selecting Transfer in a call should immediately put the other person on hold until the call connects or the Transfer is cancelled. ([#5081](https://github.com/vector-im/element-android/issues/5081))
- Fixing crashes when quickly scrolling or restoring the room timeline ([#5091](https://github.com/vector-im/element-android/issues/5091))
Changes in Element 1.3.16 (2022-01-25)
======================================

View File

@ -14,7 +14,8 @@ It is a total rewrite of [Riot-Android](https://github.com/vector-im/riot-androi
[<img src="resources/img/google-play-badge.png" alt="Get it on Google Play" height="60">](https://play.google.com/store/apps/details?id=im.vector.app)
[<img src="resources/img/f-droid-badge.png" alt="Get it on F-Droid" height="60">](https://f-droid.org/app/im.vector.app)
Nightly build: [![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop)
Nightly build: [![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop) Nighly sanity test status: [![allScreensTest](https://github.com/vector-im/element-android/actions/workflows/sanity_test.yml/badge.svg)](https://github.com/vector-im/element-android/actions/workflows/sanity_test.yml)
# New Android SDK

View File

@ -36,6 +36,12 @@ allprojects {
apply plugin: "org.jlleitschuh.gradle.ktlint"
repositories {
mavenCentral {
content {
groups.mavenCentral.regex.each { includeGroupByRegex it }
groups.mavenCentral.group.each { includeGroup it }
}
}
maven {
url 'https://jitpack.io'
content {
@ -59,12 +65,6 @@ allprojects {
groups.google.group.each { includeGroup it }
}
}
mavenCentral {
content {
groups.mavenCentral.regex.each { includeGroupByRegex it }
groups.mavenCentral.group.each { includeGroup it }
}
}
//noinspection JcenterRepositoryObsolete
jcenter {
content {
@ -144,6 +144,11 @@ project(":diff-match-patch") {
}
}
// Global configurations across all modules
ext {
isThreadingEnabled = true
}
//project(":matrix-sdk-android") {
// sonarqube {
// properties {

1
changelog.d/3907.bugfix Normal file
View File

@ -0,0 +1 @@
Fixes non sans-serif font weights being ignored

1
changelog.d/4295.misc Normal file
View File

@ -0,0 +1 @@
"Invite users to space" dialog now closed when user choose invite method

1
changelog.d/4304.misc Normal file
View File

@ -0,0 +1 @@
Changed layout for space card and room card used at "explore room" screen and space/room invite dialogs

1
changelog.d/4315.misc Normal file
View File

@ -0,0 +1 @@
Removed spaces restricted search hint dialogs

1
changelog.d/4641.misc Normal file
View File

@ -0,0 +1 @@
Remove Search from room options if not available

1
changelog.d/4746.feature Normal file
View File

@ -0,0 +1 @@
Initial implementation of thread messages

1
changelog.d/4873.misc Normal file
View File

@ -0,0 +1 @@
Qr code scanning fragments merged into one

1
changelog.d/4937.feature Normal file
View File

@ -0,0 +1 @@
Support message bubbles in timeline.

1
changelog.d/5038.bugfix Normal file
View File

@ -0,0 +1 @@
Fixing missing/intermittent notifications on the google play variant when wifi is enabled

View File

@ -1 +0,0 @@
Show the legal mention of mapbox when sharing location

View File

@ -1 +0,0 @@
Poll cannot end in some unencrypted rooms

View File

@ -1 +0,0 @@
Selecting Transfer in a call should immediately put the other person on hold until the call connects or the Transfer is cancelled.

1
changelog.d/5088.bugfix Normal file
View File

@ -0,0 +1 @@
Fixes call statuses in the timeline for missed/rejected calls and connected calls.

View File

@ -1 +0,0 @@
Fixing crashes when quickly scrolling or restoring the room timeline

1
changelog.d/5118.misc Normal file
View File

@ -0,0 +1 @@
Fix CI/CD errors after merges for quality and integration tests

1
changelog.d/5120.misc Normal file
View File

@ -0,0 +1 @@
Added automation for the Z-FTUE label to add issues to the FTUE Project Board

1
changelog.d/5128.bugfix Normal file
View File

@ -0,0 +1 @@
Fix fallback permalink when threads are disabled

1
changelog.d/5142.bugfix Normal file
View File

@ -0,0 +1 @@
Analytics: aligns use case identifying with iOS implementation

1
changelog.d/5143.bugfix Normal file
View File

@ -0,0 +1 @@
Fix location rendering in timeline if map cannot be loaded

1
changelog.d/5146.feature Normal file
View File

@ -0,0 +1 @@
Support generic location pin

1
changelog.d/5148.misc Normal file
View File

@ -0,0 +1 @@
Added automation for WTF labels to move to WTF project board

1
changelog.d/5173.misc Normal file
View File

@ -0,0 +1 @@
Update WTF automation to fix it

1
changelog.d/5175.feature Normal file
View File

@ -0,0 +1 @@
Retrieve map style url from .well-known

View File

@ -0,0 +1,2 @@
Hlavní změny v této verzi: Odeslání vlastní polohy do libovolné místnosti. Možnost úpravy hlasování.
Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Hauptänderungen: Du kannst ab sofort deinen Standort an deine Räume senden und Abstimmungen bearbeiten.
Alle Änderungen: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Main changes in this version: send your location to any room. Edit poll.
Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.17

View File

@ -0,0 +1,2 @@
Main changes in this version: send your location to any room. Edit poll.
Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.18

View File

@ -0,0 +1,2 @@
Põhilised muutused selles versioonis: oma asukoha saatmine jututuppa ja küsitluste muutmise võimalus.
Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
Principaux changements pour cette version : Premier changement dans lécran de bienvenue, y compris ladhésion aux données danalyses. Support des événements avec opération mathématiques ajoutées dans les labs.
Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.13

View File

@ -0,0 +1,2 @@
Principaux changements pour cette version : Premier changement dans lécran de bienvenue, y compris ladhésion aux données danalyses. Support des événements avec opération mathématiques ajoutées dans les labs.
Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.14

View File

@ -0,0 +1,2 @@
Principaux changements pour cette version : Premier changement dans lécran de bienvenue, y compris ladhésion aux données danalyses. Support des événements avec opération mathématiques ajoutées dans les labs.
Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.15

View File

@ -0,0 +1,2 @@
Principaux changements pour cette version : envoi de votre position dans nimporte quelle salon. Édition des sondage.
Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Fő változás ebben a verzióban: földrajzi helyzet küldése bármely szobába. Szavazás szerkesztése
Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -1,2 +1,2 @@
Perubahan utama dalam versi ini: Perubahan pertama di layar permulaan, termasuk analitik opt-in. Dukungan untuk Peristiwa dengan Matematika ditambahkan di Uji Coba.
Changelog lanjutan:
Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.13

View File

@ -0,0 +1,2 @@
Perubahan utama dalam versi ini: Kirim lokasi Anda ke ruangan apa saja. Edit poll.
Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Modifiche principali in questa versione: invia la tua posizione in qualsiasi stanza. Modifica sondaggi.
Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Principais mudanças nesta versão: envie sua localização para qualquer sala. Editar sondagem.
Changelog completo: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

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

View File

@ -0,0 +1,2 @@
Hlavné zmeny v tejto verzii: odoslanie polohy do ľubovoľnej miestnosti. Úprava ankety.
Úplný zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Ndryshimet kryesore në këtë version: Ndryshimi i parë në skenat e mirëseardhjes, përfshi zgjedhje për pjesëmarrje në Analiza. Në laboratorë u shtua mbulim për Akte me Formula Matematikore.
Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.14

View File

@ -0,0 +1,2 @@
Ndryshimet kryesore në këtë version: Ndryshimi i parë në skenat e mirëseardhjes, përfshi zgjedhje për pjesëmarrje në Analiza. Në laboratorë u shtua mbulim për Akte me Formula Matematikore.
Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.15

View File

@ -0,0 +1,2 @@
Ndryshimet kryesore në këtë version: dërgojeni vendndodhjen tuaj te cilado dhomë. Përpunoni pyetësor.
Regjistër i plotës ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Huvudsakliga ändringar i den här versionen: Första ändringen på introduktionsskärmar, inklusive opt-in för statistik. Stöd för händelser med matte tillagd i experiment.
Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.13

View File

@ -0,0 +1,2 @@
Huvudsakliga ändringar i den här versionen: Första ändringen på introduktionsskärmar, inklusive opt-in för statistik. Stöd för händelser med matte tillagd i experiment.
Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.14

View File

@ -0,0 +1,2 @@
Huvudsakliga ändringar i den här versionen: Första ändringen på introduktionsskärmar, inklusive opt-in för statistik. Stöd för händelser med matte tillagd i experiment.
Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.15

View File

@ -0,0 +1,2 @@
Huvudsakliga ändringar i den här versionen: skicka din plats till vilket rum som helst. Redigera omröstningar.
Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
Основні зміни у цій версії: надсилання свого місцеперебування у будь-яку кімнату. Редагування опитувань.
Вичерпний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,2 @@
此版本中的主要變動:將您的位置傳送給任何聊天室。編輯投票。
完整的變更紀錄https://github.com/vector-im/element-android/releases/tag/v1.3.16

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="-100%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:startOffset="250"
android:fromXDelta="100%p" android:toXDelta="0"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:startOffset="250"
android:fromXDelta="0" android:toXDelta="-100%p"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0" android:toXDelta="100%p"
android:duration="@android:integer/config_mediumAnimTime"/>
</set>

View File

@ -2,9 +2,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Tint color is provided by the theme -->
<solid android:color="@android:color/black" />
<size
android:width="240dp"
android:height="44dp" />
<corners
android:bottomLeftRadius="12dp"
android:bottomRightRadius="12dp"

View File

@ -2,16 +2,14 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="8dp" />
<solid android:color="?vctr_room_active_widgets_banner_bg" />
<shape android:shape="oval">
<solid android:color="?vctr_system" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="8dp" />
<shape android:shape="oval">
<solid android:color="@color/vctr_notice_secondary_alpha12" />
</shape>
</clip>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="menu_item_ripple_size">28dp</dimen>
</resources>

View File

@ -137,4 +137,5 @@
<attr name="vctr_presence_indicator_offline" format="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>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Timeline bubble background colors -->
<attr name="vctr_message_bubble_inbound" format="color" />
<color name="vctr_message_bubble_inbound_light">#E8EDF4</color>
<color name="vctr_message_bubble_inbound_dark">#21262C</color>
<attr name="vctr_message_bubble_outbound" format="color" />
<color name="vctr_message_bubble_outbound_light">#E7F8F3</color>
<color name="vctr_message_bubble_outbound_dark">#133A34</color>
</resources>

View File

@ -15,6 +15,8 @@
<dimen name="item_decoration_left_margin">72dp</dimen>
<dimen name="item_event_message_state_size">16dp</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>
@ -42,12 +44,23 @@
<!-- Preview Url -->
<dimen name="preview_url_view_corner_radius">8dp</dimen>
<dimen name="preview_url_view_image_max_height">160dp</dimen>
<dimen name="menu_item_icon_size">24dp</dimen>
<dimen name="menu_item_size">48dp</dimen>
<dimen name="menu_item_ripple_size">48dp</dimen>
<!-- Composer -->
<dimen name="composer_min_height">56dp</dimen>
<dimen name="composer_attachment_size">52dp</dimen>
<dimen name="composer_attachment_margin">1dp</dimen>
<dimen name="chat_bubble_margin_start">28dp</dimen>
<dimen name="chat_bubble_margin_end">62dp</dimen>
<dimen name="chat_bubble_fixed_size">300dp</dimen>
<dimen name="chat_bubble_corner_radius">12dp</dimen>
<!-- Onboarding -->
<item name="ftue_auth_gutter_start_percent" format="float" type="dimen">0.05</item>
<item name="ftue_auth_gutter_end_percent" format="float" type="dimen">0.95</item>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MessageBubble">
<attr name="incoming_style" format="boolean" />
<attr name="show_time_overlay" format="boolean" />
<attr name="is_first" format="boolean" />
<attr name="is_last" format="boolean" />
</declare-styleable>
</resources>

View File

@ -6,6 +6,7 @@
<style name="Widget.Vector.ProgressBar.Horizontal.File">
<item name="android:indeterminateOnly">false</item>
<item name="android:progressDrawable">@drawable/file_progress_bar</item>
<item name="android:progressBackgroundTint">?android:colorBackground</item>
<item name="android:minHeight">10dp</item>
<item name="android:maxHeight">40dp</item>
</style>

View File

@ -4,12 +4,23 @@
<style name="TimelineContentStubBaseParams">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginStart">8dp</item>
<item name="android:layout_marginLeft">8dp</item>
<item name="android:layout_marginEnd">8dp</item>
<item name="android:layout_marginRight">8dp</item>
<item name="android:layout_marginBottom">4dp</item>
<item name="android:layout_marginTop">4dp</item>
</style>
<style name="TimelineContentStubContainerParams">
<item name="android:paddingStart">8dp</item>
<item name="android:paddingEnd">8dp</item>
<item name="android:paddingTop">4dp</item>
<item name="android:paddingBottom">4dp</item>
</style>
<style name="TimelineContentMediaPillStyle">
<item name="android:paddingStart">8dp</item>
<item name="android:paddingEnd">8dp</item>
<item name="android:paddingTop">6dp</item>
<item name="android:paddingBottom">6dp</item>
<item name="minHeight">48dp</item>
<item name="android:background">@drawable/bg_media_pill</item>
<item name="android:backgroundTint">?vctr_content_quinary</item>
</style>
</resources>

View File

@ -31,6 +31,8 @@
<item name="vctr_waiting_background_color">@color/vctr_waiting_background_color_dark</item>
<item name="vctr_chat_effect_snow_background">@color/vctr_chat_effect_snow_background_dark</item>
<item name="vctr_toolbar_background">@color/element_system_dark</item>
<item name="vctr_message_bubble_inbound">@color/vctr_message_bubble_inbound_dark</item>
<item name="vctr_message_bubble_outbound">@color/vctr_message_bubble_outbound_dark</item>
<!-- room message colors -->
<item name="vctr_notice_secondary">#61708B</item>
@ -105,9 +107,6 @@
<!-- disable the overscroll because setOverscrollHeader/Footer don't always work -->
<item name="android:overScrollMode">never</item>
<!-- fonts -->
<item name="android:typeface">sans</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay.v14.Material</item>
<item name="pf_lock_screen">@style/PinCodeScreenStyle</item>

View File

@ -31,6 +31,8 @@
<item name="vctr_waiting_background_color">@color/vctr_waiting_background_color_light</item>
<item name="vctr_chat_effect_snow_background">@color/vctr_chat_effect_snow_background_light</item>
<item name="vctr_toolbar_background">@color/element_background_light</item>
<item name="vctr_message_bubble_inbound">@color/vctr_message_bubble_inbound_light</item>
<item name="vctr_message_bubble_outbound">@color/vctr_message_bubble_outbound_light</item>
<!-- room message colors -->
<item name="vctr_notice_secondary">#61708B</item>
@ -105,9 +107,6 @@
<!-- disable the overscroll because setOverscrollHeader/Footer don't always work -->
<item name="android:overScrollMode">never</item>
<!-- fonts -->
<item name="android:typeface">sans</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay.v14.Material</item>
<item name="pf_lock_screen">@style/PinCodeScreenStyle</item>

View File

@ -32,6 +32,8 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
typealias ThreadRootEvent = TimelineEvent
class FlowRoom(private val room: Room) {
fun liveRoomSummary(): Flow<Optional<RoomSummary>> {
@ -98,6 +100,20 @@ class FlowRoom(private val room: Room) {
fun liveNotificationState(): Flow<RoomNotificationState> {
return room.getLiveRoomNotificationState().asFlow()
}
fun liveThreadList(): Flow<List<ThreadRootEvent>> {
return room.getAllThreadsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreads()
}
}
fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> {
return room.getMarkedThreadNotificationsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getMarkedThreadNotifications()
}
}
}
fun Room.flow(): FlowRoom {

View File

@ -31,13 +31,15 @@ android {
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
buildConfigField "String", "SDK_VERSION", "\"1.3.18\""
buildConfigField "String", "SDK_VERSION", "\"1.3.19\""
buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
resValue "string", "git_sdk_revision", "\"${gitRevision()}\""
resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\""
resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\""
// Indicates whether or not threading support is enabled
buildConfigField "Boolean", "THREADING_ENABLED", "${isThreadingEnabled}"
defaultConfig {
consumerProguardFiles 'proguard-rules.pro'
}
@ -139,6 +141,9 @@ dependencies {
kapt 'dk.ilios:realmfieldnameshelper:2.0.0'
// Shared Preferences
implementation libs.androidx.preferenceKtx
// Work
implementation libs.androidx.work

View File

@ -157,14 +157,20 @@ class CommonTestHelper(context: Context) {
/**
* Will send nb of messages provided by count parameter but waits every 10 messages to avoid gap in sync
*/
private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long): List<TimelineEvent> {
private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long, rootThreadEventId: String? = null): List<TimelineEvent> {
val sentEvents = ArrayList<TimelineEvent>(count)
(1 until count + 1)
.map { "$message #$it" }
.chunked(10)
.forEach { batchedMessages ->
batchedMessages.forEach { formattedMessage ->
room.sendTextMessage(formattedMessage)
if (rootThreadEventId != null) {
room.replyInThread(
rootThreadEventId = rootThreadEventId,
replyInThreadText = formattedMessage)
} else {
room.sendTextMessage(formattedMessage)
}
}
waitWithLatch(timeout) { latch ->
val timelineListener = object : Timeline.Listener {
@ -196,6 +202,27 @@ class CommonTestHelper(context: Context) {
return sentEvents
}
/**
* Reply in a thread
* @param room the room where to send the messages
* @param message the message to send
* @param numberOfMessages the number of time the message will be sent
*/
fun replyInThreadMessage(
room: Room,
message: String,
numberOfMessages: Int,
rootThreadEventId: String,
timeout: Long = TestConstants.timeOutMillis): List<TimelineEvent> {
val timeline = room.createTimeline(null, TimelineSettings(10))
timeline.start()
val sentEvents = sendTextMessagesBatched(timeline, room, message, numberOfMessages, timeout, rootThreadEventId)
timeline.dispose()
// Check that all events has been created
assertEquals("Message number do not match $sentEvents", numberOfMessages.toLong(), sentEvents.size.toLong())
return sentEvents
}
// PRIVATE METHODS *****************************************************************************
/**

View File

@ -0,0 +1,339 @@
/*
* 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.session.room.threads
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeNull
import org.amshove.kluent.shouldBeTrue
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.getRootThreadEventId
import org.matrix.android.sdk.api.session.events.model.isTextMessage
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.room.timeline.Timeline
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 ThreadMessagingTest : InstrumentedTest {
@Test
fun reply_in_thread_should_create_a_thread() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send a message in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 1)
val initMessage = sentMessages.first()
initMessage.root.isThread().shouldBeFalse()
initMessage.root.isTextMessage().shouldBeTrue()
initMessage.root.getRootThreadEventId().shouldBeNull()
initMessage.root.threadDetails?.isRootThread?.shouldBeFalse()
// Let's reply in timeline to that message
val repliesInThread = commonTestHelper.replyInThreadMessage(
room = aliceRoom,
message = "Reply In the above thread",
numberOfMessages = 1,
rootThreadEventId = initMessage.root.eventId.orEmpty())
val replyInThread = repliesInThread.first()
replyInThread.root.isThread().shouldBeTrue()
replyInThread.root.isTextMessage().shouldBeTrue()
replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId)
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull {
it.root.eventId == initMessage.root.eventId
}?.root?.threadDetails
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
initMessageThreadDetails?.numberOfThreads?.shouldBe(1)
true
}
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
}
@Test
fun reply_in_thread_should_create_a_thread_from_other_user() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send a message in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 1)
val initMessage = sentMessages.first()
initMessage.root.isThread().shouldBeFalse()
initMessage.root.isTextMessage().shouldBeTrue()
initMessage.root.getRootThreadEventId().shouldBeNull()
initMessage.root.threadDetails?.isRootThread?.shouldBeFalse()
// Let's reply in timeline to that message from another user
val bobSession = cryptoTestData.secondSession!!
val bobRoomId = cryptoTestData.roomId
val bobRoom = bobSession.getRoom(bobRoomId)!!
val repliesInThread = commonTestHelper.replyInThreadMessage(
room = bobRoom,
message = "Reply In the above thread",
numberOfMessages = 1,
rootThreadEventId = initMessage.root.eventId.orEmpty())
val replyInThread = repliesInThread.first()
replyInThread.root.isThread().shouldBeTrue()
replyInThread.root.isTextMessage().shouldBeTrue()
replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId)
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
initMessageThreadDetails?.numberOfThreads?.shouldBe(1)
true
}
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
bobSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
initMessageThreadDetails?.numberOfThreads?.shouldBe(1)
true
}
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
bobSession.stopSync()
}
@Test
fun reply_in_thread_to_timeline_message_multiple_times() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send 5 messages in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 5)
sentMessages.forEach {
it.root.isThread().shouldBeFalse()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId().shouldBeNull()
it.root.threadDetails?.isRootThread?.shouldBeFalse()
}
// let's start the thread from the second message
val selectedInitMessage = sentMessages[1]
// Let's reply 40 times in the timeline to the second message
val repliesInThread = commonTestHelper.replyInThreadMessage(
room = aliceRoom,
message = "Reply In the above thread",
numberOfMessages = 40,
rootThreadEventId = selectedInitMessage.root.eventId.orEmpty())
repliesInThread.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(selectedInitMessage.root.eventId.orEmpty()) ?: assert(false)
}
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == selectedInitMessage.root.eventId }?.root?.threadDetails
// Selected init message should be the thread root
initMessageThreadDetails?.isRootThread?.shouldBeTrue()
// All threads should be 40
initMessageThreadDetails?.numberOfThreads?.shouldBeEqualTo(40)
true
}
// Because we sent more than 30 messages we should paginate a bit more
timeline.paginate(Timeline.Direction.BACKWARDS, 50)
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
}
@Test
fun thread_summary_advanced_validation_after_multiple_messages_in_multiple_threads() {
val commonTestHelper = CommonTestHelper(context())
val cryptoTestHelper = CryptoTestHelper(commonTestHelper)
val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false)
val aliceSession = cryptoTestData.firstSession
val aliceRoomId = cryptoTestData.roomId
val aliceRoom = aliceSession.getRoom(aliceRoomId)!!
// Let's send 5 messages in the normal timeline
val textMessage = "This is a normal timeline message"
val sentMessages = commonTestHelper.sendTextMessage(
room = aliceRoom,
message = textMessage,
nbOfMessages = 5)
sentMessages.forEach {
it.root.isThread().shouldBeFalse()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId().shouldBeNull()
it.root.threadDetails?.isRootThread?.shouldBeFalse()
}
// let's start the thread from the second message
val firstMessage = sentMessages[0]
val secondMessage = sentMessages[1]
// Alice will reply in thread to the second message 35 times
val aliceThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage(
room = aliceRoom,
message = "Alice reply In the above second thread message",
numberOfMessages = 35,
rootThreadEventId = secondMessage.root.eventId.orEmpty())
// Let's reply in timeline to that message from another user
val bobSession = cryptoTestData.secondSession!!
val bobRoomId = cryptoTestData.roomId
val bobRoom = bobSession.getRoom(bobRoomId)!!
// Bob will reply in thread to the first message 35 times
val bobThreadRepliesInFirstMessage = commonTestHelper.replyInThreadMessage(
room = bobRoom,
message = "Bob reply In the above first thread message",
numberOfMessages = 42,
rootThreadEventId = firstMessage.root.eventId.orEmpty())
// Bob will also reply in second thread 5 times
val bobThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage(
room = bobRoom,
message = "Another Bob reply In the above second thread message",
numberOfMessages = 20,
rootThreadEventId = secondMessage.root.eventId.orEmpty())
aliceThreadRepliesInSecondMessage.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false)
}
bobThreadRepliesInFirstMessage.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(firstMessage.root.eventId.orEmpty()) ?: assert(false)
}
bobThreadRepliesInSecondMessage.forEach {
it.root.isThread().shouldBeTrue()
it.root.isTextMessage().shouldBeTrue()
it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false)
}
// The init normal message should now be a root thread event
val timeline = aliceRoom.createTimeline(null, TimelineSettings(30))
timeline.start()
aliceSession.startSync(true)
run {
val lock = CountDownLatch(1)
val eventsListener = commonTestHelper.createEventListener(lock) { snapshot ->
val firstMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == firstMessage.root.eventId }?.root?.threadDetails
val secondMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == secondMessage.root.eventId }?.root?.threadDetails
// first & second message should be the thread root
firstMessageThreadDetails?.isRootThread?.shouldBeTrue()
secondMessageThreadDetails?.isRootThread?.shouldBeTrue()
// First thread message should contain 42
firstMessageThreadDetails?.numberOfThreads shouldBeEqualTo 42
// Second thread message should contain 35+20
secondMessageThreadDetails?.numberOfThreads shouldBeEqualTo 55
true
}
// Because we sent more than 30 messages we should paginate a bit more
timeline.paginate(Timeline.Direction.BACKWARDS, 50)
timeline.paginate(Timeline.Direction.BACKWARDS, 50)
timeline.addListener(eventsListener)
commonTestHelper.await(lock, 600_000)
}
aliceSession.stopSync()
}
}

View File

@ -25,9 +25,14 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.util.ContentUtils
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
@ -98,6 +103,9 @@ data class Event(
@Transient
var sendStateDetails: String? = null
@Transient
var threadDetails: ThreadDetails? = null
fun sendStateError(): MatrixError? {
return sendStateDetails?.let {
val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
@ -123,6 +131,7 @@ data class Event(
it.mCryptoErrorReason = mCryptoErrorReason
it.sendState = sendState
it.ageLocalTs = ageLocalTs
it.threadDetails = threadDetails
}
}
@ -185,6 +194,51 @@ data class Event(
return contentMap?.let { JSONObject(adapter.toJson(it)).toString(4) }
}
/**
* Returns a user friendly content depending on the message type.
* It can be used especially for message summaries.
* It will return a decrypted text message or an empty string otherwise.
*/
fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted"
val text = getDecryptedValue() ?: return null
return when {
isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
isFileMessage() -> "sent a file."
isAudioMessage() -> "sent an audio file."
isImageMessage() -> "sent an image."
isVideoMessage() -> "sent a video."
isSticker() -> "sent a sticker"
isPoll() -> getPollQuestion() ?: "created a poll."
else -> text
}
}
private fun Event.isQuote(): Boolean {
if (isReplyRenderedInThread()) return false
return getDecryptedValue("formatted_body")?.contains("<blockquote>") ?: false
}
/**
* Determines whether or not current event has mentioned the user
*/
fun isUserMentioned(userId: String): Boolean {
return getDecryptedValue("formatted_body")?.contains(userId) ?: false
}
/**
* Decrypt the message, or return the pure payload value if there is no encryption
*/
private fun getDecryptedValue(key: String = "body"): String? {
return if (isEncrypted()) {
@Suppress("UNCHECKED_CAST")
val decryptedContent = mxDecryptionResult?.payload?.get("content") as? JsonDict
decryptedContent?.get(key) as? String
} else {
content?.get(key) as? String
}
}
/**
* Tells if the event is redacted
*/
@ -217,7 +271,7 @@ data class Event(
if (mCryptoError != other.mCryptoError) return false
if (mCryptoErrorReason != other.mCryptoErrorReason) return false
if (sendState != other.sendState) return false
if (threadDetails != other.threadDetails) return false
return true
}
@ -236,6 +290,8 @@ data class Event(
result = 31 * result + (mCryptoError?.hashCode() ?: 0)
result = 31 * result + (mCryptoErrorReason?.hashCode() ?: 0)
result = 31 * result + sendState.hashCode()
result = 31 * result + threadDetails.hashCode()
return result
}
}
@ -243,70 +299,101 @@ data class Event(
fun Event.isTextMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_NOTICE -> true
else -> false
}
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_NOTICE -> true
else -> false
}
}
fun Event.isImageMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_IMAGE -> true
else -> false
}
MessageType.MSGTYPE_IMAGE -> true
else -> false
}
}
fun Event.isVideoMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_VIDEO -> true
else -> false
}
MessageType.MSGTYPE_VIDEO -> true
else -> false
}
}
fun Event.isAudioMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_AUDIO -> true
else -> false
}
MessageType.MSGTYPE_AUDIO -> true
else -> false
}
}
fun Event.isFileMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_FILE -> true
else -> false
}
MessageType.MSGTYPE_FILE -> true
else -> false
}
}
fun Event.isAttachmentMessage(): Boolean {
return getClearType() == EventType.MESSAGE &&
when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) {
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
}
fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END
fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
fun Event.getRelationContent(): RelationDefaultContent? {
return if (isEncrypted()) {
content.toModel<EncryptedEventContent>()?.relatesTo
} else {
content.toModel<MessageContent>()?.relatesTo
content.toModel<MessageContent>()?.relatesTo ?: run {
// Special case to handle stickers, while there is only a local msgtype for stickers
if (getClearType() == EventType.STICKER) {
getClearContent().toModel<MessageStickerContent>()?.relatesTo
} else {
null
}
}
}
}
/**
* Returns the poll question or null otherwise
*/
fun Event.getPollQuestion(): String? =
getPollContent()?.pollCreationInfo?.question?.question
/**
* Returns the relation content for a specific type or null otherwise
*/
fun Event.getRelationContentForType(type: String): RelationDefaultContent? =
getRelationContent()?.takeIf { it.type == type }
fun Event.isReply(): Boolean {
return getRelationContent()?.inReplyTo?.eventId != null
}
fun Event.isReplyRenderedInThread(): Boolean {
return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true
}
fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null
fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_THREAD)?.eventId
fun Event.isEdition(): Boolean {
return getRelationContent()?.takeIf { it.type == RelationType.REPLACE }?.eventId != null
return getRelationContentForType(RelationType.REPLACE)?.eventId != null
}
fun Event.getPresenceContent(): PresenceContent? {
@ -315,3 +402,7 @@ fun Event.getPresenceContent(): PresenceContent? {
fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
content?.toModel<RoomMemberContent>()?.membership == Membership.INVITE
fun Event.getPollContent(): MessagePollContent? {
return content.toModel<MessagePollContent>()
}

View File

@ -28,9 +28,9 @@ object RelationType {
/** Lets you define an event which references an existing event.*/
const val REFERENCE = "m.reference"
/** Lets you define an thread event that belongs to another existing event.*/
// const val THREAD = "m.thread" // m.thread is not yet released in the backend
const val THREAD = "io.element.thread" // io.element.thread will be replaced by m.thread when it is released
/** Lets you define an event which is a thread reply to an existing event.*/
const val THREAD = "m.thread"
const val IO_THREAD = "io.element.thread"
/** Lets you define an event which adds a response to an existing event.*/
const val RESPONSE = "org.matrix.response"

View File

@ -47,5 +47,9 @@ data class PreviewUrlData(
// Value of field "og:description"
val description: String?,
// Value of field "og:image"
val mxcUrl: String?
val mxcUrl: String?,
// Value of field "og:image:width"
val imageWidth: Int?,
// Value of field "og:image:height"
val imageHeight: Int?
)

View File

@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.send.DraftService
import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService
import org.matrix.android.sdk.api.session.room.threads.ThreadsService
import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.util.Optional
*/
interface Room :
TimelineService,
ThreadsService,
SendService,
DraftService,
ReadService,

View File

@ -63,5 +63,13 @@ data class MessageLocationContent(
@Json(name = "org.matrix.msc1767.text") val text: String? = null
) : MessageContent {
fun getUri() = locationInfo?.geoUri ?: geoUri
fun getBestGeoUri() = locationInfo?.geoUri ?: geoUri
/**
* @return true if the location asset is a user location, not a generic one.
*/
fun isSelfLocation(): Boolean {
// Should behave like m.self if locationAsset is null
return locationAsset?.type == null || locationAsset.type == LocationAssetType.SELF
}
}

View File

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.relation
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable
@ -45,6 +46,9 @@ import org.matrix.android.sdk.api.util.Optional
* m.reference - lets you define an event which references an existing event.
* When aggregated, currently doesn't do anything special, but in future could bundle chains of references (i.e. threads).
* These are primarily intended for handling replies (and in future threads).
*
* m.thread - lets you define an event which is a thread reply to an existing event.
* When aggregated, returns the most thread event
*/
interface RelationService {
@ -62,8 +66,8 @@ interface RelationService {
* @param targetEventId the id of the event being reacted
* @param reaction the reaction (preferably emoji)
*/
fun undoReaction(targetEventId: String,
reaction: String): Cancelable
suspend fun undoReaction(targetEventId: String,
reaction: String): Cancelable
/**
* Edit a poll.
@ -118,10 +122,15 @@ interface RelationService {
* @param eventReplied the event referenced by the reply
* @param replyText the reply text
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @param showInThread If true, relation will be added to the reply in order to be visible from within threads
* @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation
*/
fun replyToMessage(eventReplied: TimelineEvent,
replyText: CharSequence,
autoMarkdown: Boolean = false): Cancelable?
autoMarkdown: Boolean = false,
showInThread: Boolean = false,
rootThreadEventId: String? = null
): Cancelable?
/**
* Get the current EventAnnotationsSummary
@ -136,4 +145,31 @@ interface RelationService {
* @return the LiveData of EventAnnotationsSummary
*/
fun getEventAnnotationsSummaryLive(eventId: String): LiveData<Optional<EventAnnotationsSummary>>
/**
* Creates a thread reply for an existing timeline event
* The replyInThreadText can be a Spannable and contains special spans (MatrixItemSpan) that will be translated
* by the sdk into pills.
* @param rootThreadEventId the root thread eventId
* @param replyInThreadText the reply text
* @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
* @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @param eventReplied the event referenced by the reply within a thread
*/
fun replyInThread(rootThreadEventId: String,
replyInThreadText: CharSequence,
msgType: String = MessageType.MSGTYPE_TEXT,
autoMarkdown: Boolean = false,
formattedText: String? = null,
eventReplied: TimelineEvent? = null): Cancelable?
/**
* Get all the thread replies for the specified rootThreadEventId
* The return list will contain the original root thread event and all the thread replies to that event
* Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready
* from the backend
* @param rootThreadEventId the root thread eventId
*/
suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean
}

View File

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

@ -64,7 +64,7 @@ interface SendService {
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @return a [Cancelable]
*/
fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable
fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable
/**
* Method to send a media asynchronously.
@ -72,11 +72,13 @@ interface SendService {
* @param compressBeforeSending set to true to compress images before sending them
* @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
* It can be useful to send media to multiple room. It's safe to include the current roomId in this set
* @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread
* @return a [Cancelable]
*/
fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable
roomIds: Set<String>,
rootThreadEventId: String? = null): Cancelable
/**
* Method to send a list of media asynchronously.
@ -84,11 +86,13 @@ interface SendService {
* @param compressBeforeSending set to true to compress images before sending them
* @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
* It can be useful to send media to multiple room. It's safe to include the current roomId in this set
* @param rootThreadEventId when this param is not null, all the Media will be sent in this specific thread
* @return a [Cancelable]
*/
fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable
roomIds: Set<String>,
rootThreadEventId: String? = null): Cancelable
/**
* Send a poll to the room.

View File

@ -0,0 +1,67 @@
/*
* 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
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* This interface defines methods to interact with threads related features.
* It's implemented at the room level within the main timeline.
*/
interface ThreadsService {
/**
* 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

@ -43,7 +43,7 @@ interface Timeline {
/**
* This must be called before any other method after creating the timeline. It ensures the underlying database is open
*/
fun start()
fun start(rootThreadEventId: String? = null)
/**
* This must be called when you don't need the timeline. It ensures the underlying database get closed.

View File

@ -22,7 +22,9 @@ 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.getRelationContent
import org.matrix.android.sdk.api.session.events.model.isEdition
import org.matrix.android.sdk.api.session.events.model.isPoll
import org.matrix.android.sdk.api.session.events.model.isReply
import org.matrix.android.sdk.api.session.events.model.isSticker
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
@ -149,6 +151,13 @@ fun TimelineEvent.isEdition(): Boolean {
return root.isEdition()
}
fun TimelineEvent.isPoll(): Boolean =
root.isPoll()
fun TimelineEvent.isSticker(): Boolean {
return root.isSticker()
}
/**
* Get the latest message body, after a possible edition, stripping the reply prefix if necessary
*/

View File

@ -27,5 +27,14 @@ data class TimelineSettings(
/**
* If true, will build read receipts for each event.
*/
val buildReadReceipts: Boolean = true
)
val buildReadReceipts: Boolean = true,
/**
* The root thread eventId if this is a thread timeline, or null if this is NOT a thread timeline
*/
val rootThreadEventId: String? = null) {
/**
* Returns true if this is a thread timeline or false otherwise
*/
fun isThreadTimeline() = rootThreadEventId != null
}

View File

@ -0,0 +1,34 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.threads
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
/**
* This class contains all the details needed for threads.
* Is is mainly used from within an Event.
*/
data class ThreadDetails(
val isRootThread: Boolean = false,
val numberOfThreads: Int = 0,
val threadSummarySenderInfo: SenderInfo? = null,
val threadSummaryLatestTextMessage: String? = null,
val lastMessageTimestamp: Long? = null,
var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE,
val isThread: Boolean = false,
val lastRootThreadEdition: String? = null
)

View File

@ -0,0 +1,25 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.threads
/**
* This class defines the state of a thread notification badge
*/
data class ThreadNotificationBadgeState(
val numberOfLocalUnreadThreads: Int = 0,
val isUserMentioned: Boolean = false
)

View File

@ -0,0 +1,33 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.threads
/**
* This class defines the state of a thread notification
*/
enum class ThreadNotificationState {
// There are no new message
NO_NEW_MESSAGE,
// There is at least one new message
NEW_MESSAGE,
// The is at least one new message that should be highlighted
// ex. "Hello @aris.kotsomitopoulos"
NEW_HIGHLIGHTED_MESSAGE;
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.threads
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* This class contains a thread TimelineEvent along with a boolean that
* determines if the current user has participated in that event
*/
data class ThreadTimelineEvent(
val timelineEvent: TimelineEvent,
val isParticipating: Boolean
)

View File

@ -35,7 +35,7 @@ internal class MXOutboundSessionInfo(
val sessionLifetime = System.currentTimeMillis() - creationTime
if (useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) {
Timber.v("## needsRotation() : Rotating megolm session after " + useCount + ", " + sessionLifetime + "ms")
Timber.v("## needsRotation() : Rotating megolm session after $useCount, ${sessionLifetime}ms")
needsRotation = true
}

View File

@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent
import org.matrix.android.sdk.api.session.room.model.VersioningState
import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.crypto.model.event.EncryptionEventContent
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields
@ -56,7 +57,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
) : RealmMigration {
companion object {
const val SESSION_STORE_SCHEMA_VERSION = 21L
const val SESSION_STORE_SCHEMA_VERSION = 24L
}
/**
@ -90,6 +91,9 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion <= 18) migrateTo19(realm)
if (oldVersion <= 19) migrateTo20(realm)
if (oldVersion <= 20) migrateTo21(realm)
if (oldVersion <= 21) migrateTo22(realm)
if (oldVersion <= 22) migrateTo23(realm)
if (oldVersion <= 23) migrateTo24(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -445,4 +449,44 @@ internal class RealmSessionStoreMigration @Inject constructor(
}
}
}
private fun migrateTo22(realm: DynamicRealm) {
Timber.d("Step 21 -> 22")
val listJoinedRoomIds = realm.where("RoomEntity")
.equalTo(RoomEntityFields.MEMBERSHIP_STR, Membership.JOIN.name).findAll()
.map { it.getString(RoomEntityFields.ROOM_ID) }
val hasMissingStateEvent = realm.where("CurrentStateEventEntity")
.`in`(CurrentStateEventEntityFields.ROOM_ID, listJoinedRoomIds.toTypedArray())
.isNull(CurrentStateEventEntityFields.ROOT.`$`).findFirst() != null
if (hasMissingStateEvent) {
Timber.v("Has some missing state event, clear session cache")
realm.deleteAll()
}
}
private fun migrateTo23(realm: DynamicRealm) {
Timber.d("Step 22 -> 23")
val eventEntity = realm.schema.get("TimelineEventEntity") ?: return
realm.schema.get("EventEntity")
?.addField(EventEntityFields.IS_ROOT_THREAD, Boolean::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.NUMBER_OF_THREADS, Int::class.java)
?.addField(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, String::class.java)
?.transform {
it.setString(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NO_NEW_MESSAGE.name)
}
?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity)
}
private fun migrateTo24(realm: DynamicRealm) {
Timber.d("Step 23 -> 24")
realm.schema.get("PreviewUrlCacheEntity")
?.addField(PreviewUrlCacheEntityFields.IMAGE_WIDTH, Int::class.java)
?.setNullable(PreviewUrlCacheEntityFields.IMAGE_WIDTH, true)
?.addField(PreviewUrlCacheEntityFields.IMAGE_HEIGHT, Int::class.java)
?.setNullable(PreviewUrlCacheEntityFields.IMAGE_HEIGHT, true)
}
}

View File

@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.database.query.whereRoomId
import org.matrix.android.sdk.internal.extensions.assertIsManaged
import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
import timber.log.Timber
@ -81,7 +82,7 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity,
internal fun ChunkEntity.addTimelineEvent(roomId: String,
eventEntity: EventEntity,
direction: PaginationDirection,
roomMemberContentsByUser: Map<String, RoomMemberContent?>) {
roomMemberContentsByUser: Map<String, RoomMemberContent?>? = null) {
val eventId = eventEntity.eventId
if (timelineEvents.find(eventId) != null) {
return
@ -101,7 +102,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String,
?.also { it.cleanUp(eventEntity.sender) }
this.readReceipts = readReceiptsSummaryEntity
this.displayIndex = displayIndex
val roomMemberContent = roomMemberContentsByUser[senderId]
val roomMemberContent = roomMemberContentsByUser?.get(senderId)
this.senderAvatar = roomMemberContent?.avatarUrl
this.senderName = roomMemberContent?.displayName
isUniqueDisplayName = if (roomMemberContent?.displayName != null) {
@ -157,9 +158,21 @@ private fun ChunkEntity.addTimelineEventFromMerge(realm: Realm, timelineEventEnt
this.senderName = timelineEventEntity.senderName
this.isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName
}
handleThreadSummary(realm, eventId, copied)
timelineEvents.add(copied)
}
/**
* Upon copy of the timeline events we should update the latestMessage TimelineEventEntity with the new one
*/
private fun handleThreadSummary(realm: Realm, oldEventId: String, newTimelineEventEntity: TimelineEventEntity) {
EventEntity
.whereRoomId(realm, newTimelineEventEntity.roomId)
.equalTo(EventEntityFields.IS_ROOT_THREAD, true)
.equalTo(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.EVENT_ID, oldEventId)
.findFirst()?.threadSummaryLatestMessage = newTimelineEventEntity
}
private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventEntity, senderId: String): ReadReceiptsSummaryEntity {
val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventEntity.eventId).findFirst()
?: realm.createObject<ReadReceiptsSummaryEntity>(eventEntity.eventId).apply {

View File

@ -0,0 +1,321 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.database.helper
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.Sort
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.findIncludingEvent
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.database.query.whereRoomId
private typealias ThreadSummary = Pair<Int, TimelineEventEntity>?
/**
* Finds the root thread event and update it with the latest message summary along with the number
* of threads included. If there is no root thread event no action is done
*/
internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded(
roomId: String,
realm: Realm, currentUserId: String,
chunkEntity: ChunkEntity? = null,
shouldUpdateNotifications: Boolean = true) {
for ((rootThreadEventId, eventEntity) in this) {
eventEntity.threadSummaryInThread(eventEntity.realm, rootThreadEventId, chunkEntity)?.let { threadSummary ->
val numberOfMessages = threadSummary.first
val latestEventInThread = threadSummary.second
// If this is a thread message, find its root event if exists
val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity
rootThreadEvent?.markEventAsRoot(
threadsCounted = numberOfMessages,
latestMessageTimelineEventEntity = latestEventInThread
)
}
}
if (shouldUpdateNotifications) {
updateNotificationsNew(roomId, realm, currentUserId)
}
}
/**
* Finds the root event of the the current thread event message.
* Returns the EventEntity or null if the root event do not exist
*/
internal fun EventEntity.findRootThreadEvent(): EventEntity? =
rootThreadEventId?.let {
EventEntity
.where(realm, it)
.findFirst()
}
/**
* Mark or update the current event a root thread event
*/
internal fun EventEntity.markEventAsRoot(
threadsCounted: Int,
latestMessageTimelineEventEntity: TimelineEventEntity?) {
isRootThread = true
numberOfThreads = threadsCounted
threadSummaryLatestMessage = latestMessageTimelineEventEntity
}
/**
* Count the number of threads for the provided root thread eventId, and finds the latest event message
* @param rootThreadEventId The root eventId that will find the number of threads
* @return A ThreadSummary containing the counted threads and the latest event message
*/
internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary {
// Number of messages
val messages = TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.count()
.toInt()
if (messages <= 0) return null
// Find latest thread event, we know it exists
var chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) ?: chunkEntity ?: return null
var result: TimelineEventEntity? = null
// Iterate the chunk until we find our latest event
while (result == null) {
result = findLatestSortedChunkEvent(chunk, rootThreadEventId)
chunk = ChunkEntity.find(realm, roomId, nextToken = chunk.prevToken) ?: break
}
if (result == null && chunkEntity != null) {
// Find latest event from our current chunk
result = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId)
} else if (result != null && chunkEntity != null) {
val currentChunkLatestEvent = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId)
result = findMostRecentEvent(result, currentChunkLatestEvent)
}
result ?: return null
return ThreadSummary(messages, result)
}
/**
* Lets compare them in case user is moving forward in the timeline and we cannot know the
* exact chunk sequence while currentChunk is not yet committed in the DB
*/
private fun findMostRecentEvent(result: TimelineEventEntity, currentChunkLatestEvent: TimelineEventEntity?): TimelineEventEntity {
currentChunkLatestEvent ?: return result
val currentChunkEventTimestamp = currentChunkLatestEvent.root?.originServerTs ?: return result
val resultTimestamp = result.root?.originServerTs ?: return result
if (currentChunkEventTimestamp > resultTimestamp) {
return currentChunkLatestEvent
}
return result
}
/**
* Find the latest event of the current chunk
*/
private fun findLatestSortedChunkEvent(chunk: ChunkEntity, rootThreadEventId: String): TimelineEventEntity? =
chunk.timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)?.firstOrNull {
it.root?.rootThreadEventId == rootThreadEventId
}
/**
* Find all TimelineEventEntity that are root threads for the specified room
* @param roomId The room that all stored root threads will be returned
*/
internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery<TimelineEventEntity> =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
.sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING)
/**
* Map each root thread TimelineEvent with the equivalent decrypted text edition/replacement
*/
internal fun List<TimelineEvent>.mapEventsWithEdition(realm: Realm, roomId: String): List<TimelineEvent> =
this.map {
EventAnnotationsSummaryEntity
.where(realm, roomId, eventId = it.eventId)
.findFirst()
?.editSummary
?.editions
?.lastOrNull()
?.eventId
?.let { editedEventId ->
TimelineEventEntity.where(realm, roomId, eventId = editedEventId).findFirst()?.let { editedEvent ->
it.root.threadDetails = it.root.threadDetails?.copy(lastRootThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary()
?: "(edited)")
it
} ?: it
} ?: it
}
/**
* Returns a list of all the marked unread threads that exists for the specified room
* @param roomId The roomId that the user is currently in
*/
internal fun TimelineEventEntity.Companion.findAllLocalThreadNotificationsForRoomId(realm: Realm, roomId: String): RealmQuery<TimelineEventEntity> =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
.beginGroup()
.equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_MESSAGE.name)
.or()
.equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE.name)
.endGroup()
/**
* Returns whether or not the given user is participating in a current thread
* @param roomId the room that the thread exists
* @param rootThreadEventId the thread that the search will be done
* @param senderId the user that will try to find participation
*/
internal fun TimelineEventEntity.Companion.isUserParticipatingInThread(realm: Realm, roomId: String, rootThreadEventId: String, senderId: String): Boolean =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.equalTo(TimelineEventEntityFields.ROOT.SENDER, senderId)
.findFirst()
?.let { true }
?: false
/**
* Returns whether or not the given user is mentioned in a current thread
* @param roomId the room that the thread exists
* @param rootThreadEventId the thread that the search will be done
* @param userId the user that will try to find if there is a mention
*/
internal fun TimelineEventEntity.Companion.isUserMentionedInThread(realm: Realm, roomId: String, rootThreadEventId: String, userId: String): Boolean =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.equalTo(TimelineEventEntityFields.ROOT.SENDER, userId)
.findAll()
.firstOrNull { isUserMentioned(userId, it) }
?.let { true }
?: false
/**
* Find the read receipt for the current user
*/
internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String): String? =
ReadReceiptEntity.where(realm, roomId = roomId, userId = userId)
.findFirst()
?.eventId
/**
* Returns whether or not the user is mentioned in the event
*/
internal fun isUserMentioned(currentUserId: String, timelineEventEntity: TimelineEventEntity?): Boolean {
return timelineEventEntity?.root?.asDomain()?.isUserMentioned(currentUserId) == true
}
/**
* Update badge notifications. Count the number of new thread events after the latest
* read receipt and aggregate. This function will find and notify new thread events
* that the user is either mentioned, or the user had participated in.
* Important: If the root thread event is not fetched notification will not work
* Important: It will work only with the latest chunk, while read marker will be changed
* immediately so we should not display wrong notifications
*/
internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId: String) {
val readReceipt = findMyReadReceipt(realm, roomId, currentUserId) ?: return
val readReceiptChunk = ChunkEntity
.findIncludingEvent(realm, readReceipt) ?: return
val readReceiptChunkTimelineEvents = readReceiptChunk
.timelineEvents
.where()
.equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
.findAll() ?: return
val readReceiptChunkPosition = readReceiptChunkTimelineEvents.indexOfFirst { it.eventId == readReceipt }
if (readReceiptChunkPosition == -1) return
if (readReceiptChunkPosition < readReceiptChunkTimelineEvents.lastIndex) {
// If the read receipt is found inside the chunk
val threadEventsAfterReadReceipt = readReceiptChunkTimelineEvents
.slice(readReceiptChunkPosition..readReceiptChunkTimelineEvents.lastIndex)
.filter { it.root?.isThread() == true }
// In order for the below code to work for old events, we should save the previous read receipt
// and then continue with the chunk search for that read receipt
/*
val newThreadEventsList = arrayListOf<TimelineEventEntity>()
newThreadEventsList.addAll(threadEventsAfterReadReceipt)
// got from latest chunk all new threads, lets move to the others
var nextChunk = ChunkEntity
.find(realm = realm, roomId = roomId, nextToken = readReceiptChunk.nextToken)
.takeIf { readReceiptChunk.nextToken != null }
while (nextChunk != null) {
newThreadEventsList.addAll(nextChunk.timelineEvents
.filter { it.root?.isThread() == true })
nextChunk = ChunkEntity
.find(realm = realm, roomId = roomId, nextToken = nextChunk.nextToken)
.takeIf { readReceiptChunk.nextToken != null }
}*/
// Find if the user is mentioned in those events
val userMentionsList = threadEventsAfterReadReceipt
.filter {
isUserMentioned(currentUserId = currentUserId, it)
}.map {
it.root?.rootThreadEventId
}
// Find the root events in the new thread events
val rootThreads = threadEventsAfterReadReceipt.distinctBy { it.root?.rootThreadEventId }.mapNotNull { it.root?.rootThreadEventId }
// Update root thread events only if the user have participated in
rootThreads.forEach { eventId ->
val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
realm = realm,
roomId = roomId,
rootThreadEventId = eventId,
senderId = currentUserId)
val rootThreadEventEntity = EventEntity.where(realm, eventId).findFirst()
if (isUserParticipating) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
}
if (userMentionsList.contains(eventId)) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
}
}
}
}

View File

@ -0,0 +1,47 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.database.lightweight
import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import javax.inject.Inject
/**
* The purpose of this class is to provide an alternative and lightweight way to store settings/data
* on the sdi without using the database. This should be used just for sdk/user preferences and
* not for large data sets
*/
class LightweightSettingsStorage @Inject constructor(context: Context) {
private val sdkDefaultPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
fun setThreadMessagesEnabled(enabled: Boolean) {
sdkDefaultPrefs.edit {
putBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, enabled)
}
}
fun areThreadMessagesEnabled(): Boolean {
return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, false)
}
companion object {
const val MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED = "MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED"
}
}

View File

@ -21,7 +21,11 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.UnsignedData
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.di.MoshiProvider
@ -51,6 +55,10 @@ internal object EventMapper {
}
eventEntity.decryptionErrorReason = event.mCryptoErrorReason
eventEntity.decryptionErrorCode = event.mCryptoError?.name
eventEntity.isRootThread = event.threadDetails?.isRootThread ?: false
eventEntity.rootThreadEventId = event.getRootThreadEventId()
eventEntity.numberOfThreads = event.threadDetails?.numberOfThreads ?: 0
eventEntity.threadNotificationState = event.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE
return eventEntity
}
@ -93,6 +101,23 @@ internal object EventMapper {
MXCryptoError.ErrorType.valueOf(errorCode)
}
it.mCryptoErrorReason = eventEntity.decryptionErrorReason
it.threadDetails = ThreadDetails(
isRootThread = eventEntity.isRootThread,
isThread = if (it.threadDetails?.isThread == true) true else eventEntity.isThread(),
numberOfThreads = eventEntity.numberOfThreads,
threadSummarySenderInfo = eventEntity.threadSummaryLatestMessage?.let { timelineEventEntity ->
SenderInfo(
userId = timelineEventEntity.root?.sender ?: "",
displayName = timelineEventEntity.senderName,
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
avatarUrl = timelineEventEntity.senderAvatar
)
},
threadNotificationState = eventEntity.threadNotificationState,
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(),
lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs
)
}
}
}
@ -101,9 +126,15 @@ internal fun EventEntity.asDomain(castJsonNumbers: Boolean = false): Event {
return EventMapper.map(this, castJsonNumbers)
}
internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?): EventEntity {
internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?, contentToInject: String? = null): EventEntity {
return EventMapper.map(this, roomId).apply {
this.sendState = sendState
this.ageLocalTs = ageLocalTs
contentToInject?.let {
this.content = it
if (this.type == EventType.STICKER) {
this.type = EventType.MESSAGE
}
}
}
}

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