diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5ccd00a02b..c72a29812e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,6 +14,7 @@ jobs: - name: Run code quality check suite run: ./tools/check/check_code_quality.sh +# ktlint for all the modules ktlint: name: Kotlin Linter runs-on: ubuntu-latest @@ -23,12 +24,55 @@ jobs: run: | ./gradlew ktlintCheck --continue - name: Upload reports + if: always() uses: actions/upload-artifact@v2 with: name: ktlinting-report - path: vector/build/reports/ktlint/*.* + path: | + */build/reports/ktlint/ktlint*/ktlint*.txt + - name: Handle Results + if: always() + id: get-comment-body + run: | + results="$(cat */*/build/reports/ktlint/ktlint*/ktlint*.txt */build/reports/ktlint/ktlint*/ktlint*.txt | sed -r "s/\x1B\[([0-9]{1,3}(;[0-9]{1,2})?)?[mGK]//g")" + if [ -z "$results" ]; then + body="👍 ✅ 👍" + else + body="👎 ❌ 👎 \`Failed${results}\`" + body="${body//'%'/'%25'}" + body="${body//$'\n'/'%0A'}" + body="${body//$'\r'/'%0D'}" + body="$( echo $body | sed 's/\/home\/runner\/work\/element-android\/element-android\//\`\`/g')" + body="$( echo $body | sed 's/\/src\/main\/java\// 🔸 /g')" + body="$( echo $body | sed 's/im\/vector\/app\///g')" + body="$( echo $body | sed 's/im\/vector\/lib\/attachmentviewer\///g')" + body="$( echo $body | sed 's/im\/vector\/lib\/multipicker\///g')" + body="$( echo $body | sed 's/im\/vector\/lib\///g')" + body="$( echo $body | sed 's/org\/matrix\/android\/sdk\///g')" + body="$( echo $body | sed 's/\/src\/androidTest\/java\// 🔸 /g')" + fi + echo "::set-output name=body::$body" + - name: Find Comment + if: always() + uses: peter-evans/find-comment@v1 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: Ktlint Results + - name: Publish ktlint results to PR + if: always() + uses: peter-evans/create-or-update-comment@v1 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + ### Ktlint Results -# Lint for main module and all the other modules + ${{ steps.get-comment-body.outputs.body }} + edit-mode: replace + +# Lint for main module android-lint: name: Android Linter runs-on: ubuntu-latest @@ -74,7 +118,6 @@ jobs: run: ./gradlew clean lint${{ matrix.target }}Release --stacktrace - name: Upload ${{ matrix.target }} linting report uses: actions/upload-artifact@v2 - if: always() with: name: release-lint-report-${{ matrix.target }} path: | diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml index 4ecc824424..6a22bf5223 100644 --- a/.github/workflows/triage-incoming.yml +++ b/.github/workflows/triage-incoming.yml @@ -7,6 +7,8 @@ on: jobs: automate-project-columns: runs-on: ubuntu-latest + # Skip in forks + if: github.repository == 'vector-im/element-android' steps: - uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488 with: diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml index 67c4e9dbab..e2f5cc32e9 100644 --- a/.github/workflows/triage-move-labelled.yml +++ b/.github/workflows/triage-move-labelled.yml @@ -3,11 +3,13 @@ name: Move labelled issues to correct boards and columns on: issues: types: [labeled] - + jobs: move_needs_info_issues: name: X-Needs-Info issues to Need info column on triage board runs-on: ubuntu-latest + # Skip in forks + if: github.repository == 'vector-im/element-android' steps: - uses: konradpabjan/move-labeled-or-milestoned-issue@219d384e03fa4b6460cd24f9f37d19eb033a4338 with: @@ -19,15 +21,17 @@ jobs: add_priority_design_issues_to_project: name: P1 X-Needs-Design to Design project board runs-on: ubuntu-latest + # Skip in forks if: > - contains(github.event.issue.labels.*.name, 'X-Needs-Design') && - (contains(github.event.issue.labels.*.name, 'S-Critical') && - (contains(github.event.issue.labels.*.name, 'O-Frequent') || + github.repository == 'vector-im/element-android' && + contains(github.event.issue.labels.*.name, 'X-Needs-Design') && + (contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || contains(github.event.issue.labels.*.name, 'O-Occasional')) || - contains(github.event.issue.labels.*.name, 'S-Major') && - contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'A11y') && - contains(github.event.issue.labels.*.name, 'O-Frequent')) + contains(github.event.issue.labels.*.name, 'S-Major') && + contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'A11y') && + contains(github.event.issue.labels.*.name, 'O-Frequent')) steps: - uses: octokit/graphql-action@v2.x id: add_to_project @@ -47,36 +51,40 @@ jobs: PROJECT_ID: "PN_kwDOAM0swc0sUA" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} -# delight_issues_to_board: -# name: Spaces issues to new Delight project board -# runs-on: ubuntu-latest -# if: > -# contains(github.event.issue.labels.*.name, 'A-Spaces') || -# contains(github.event.issue.labels.*.name, 'A-Space-Settings') || -# contains(github.event.issue.labels.*.name, 'A-Subspaces') -# 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_kwDOAM0swc1HvQ" -# GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + # delight_issues_to_board: + # name: Spaces issues to new Delight project board + # runs-on: ubuntu-latest + # # Skip in forks + # if: > + # github.repository == 'vector-im/element-android' && + # contains(github.event.issue.labels.*.name, 'A-Spaces') || + # contains(github.event.issue.labels.*.name, 'A-Space-Settings') || + # contains(github.event.issue.labels.*.name, 'A-Subspaces') + # 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_kwDOAM0swc1HvQ" + # GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_voice-message_issues: name: A-Voice Messages to voice message board runs-on: ubuntu-latest + # Skip in forks if: > - contains(github.event.issue.labels.*.name, 'A-Voice Messages') + github.repository == 'vector-im/element-android' && + contains(github.event.issue.labels.*.name, 'A-Voice Messages') steps: - uses: octokit/graphql-action@v2.x with: @@ -98,8 +106,10 @@ jobs: move_threads_issues: name: A-Threads to Thread board runs-on: ubuntu-latest + # Skip in forks if: > - contains(github.event.issue.labels.*.name, 'A-Threads') + github.repository == 'vector-im/element-android' && + contains(github.event.issue.labels.*.name, 'A-Threads') steps: - uses: octokit/graphql-action@v2.x with: @@ -121,8 +131,10 @@ jobs: move_message_bubbles_issues: name: A-Message-Bubbles to Message bubbles board runs-on: ubuntu-latest + # Skip in forks if: > - contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') + github.repository == 'vector-im/element-android' && + contains(github.event.issue.labels.*.name, 'A-Message-Bubbles') steps: - uses: octokit/graphql-action@v2.x with: diff --git a/.github/workflows/triage-move-unlabelled.yml b/.github/workflows/triage-move-unlabelled.yml index 94bd049b91..eb90cdb503 100644 --- a/.github/workflows/triage-move-unlabelled.yml +++ b/.github/workflows/triage-move-unlabelled.yml @@ -3,14 +3,15 @@ name: Move unlabelled from needs info columns to triaged on: issues: types: [unlabeled] - + jobs: Move_Unabeled_Issue_On_Project_Board: name: Move no longer X-Needs-Info issues to Triaged runs-on: ubuntu-latest + # Skip in forks if: > - ${{ - !contains(github.event.issue.labels.*.name, 'X-Needs-Info') }} + github.repository == 'vector-im/element-android' && + !contains(github.event.issue.labels.*.name, 'X-Needs-Info') env: BOARD_NAME: "Issue triage" OWNER: ${{ github.repository_owner }} diff --git a/.github/workflows/triage-priority-bugs.yml b/.github/workflows/triage-priority-bugs.yml index 976879a3ae..daea78de19 100644 --- a/.github/workflows/triage-priority-bugs.yml +++ b/.github/workflows/triage-priority-bugs.yml @@ -7,23 +7,25 @@ on: jobs: p1_issues_to_team_workboard: runs-on: ubuntu-latest + # Skip in forks if: > - (!contains(github.event.issue.labels.*.name, 'A-E2EE') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') && - !contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') && - !contains(github.event.issue.labels.*.name, 'A-Spaces') && - !contains(github.event.issue.labels.*.name, 'A-Spaces-Settings') && - !contains(github.event.issue.labels.*.name, 'A-Subspaces')) && - (contains(github.event.issue.labels.*.name, 'T-Defect') && - contains(github.event.issue.labels.*.name, 'S-Critical') && - (contains(github.event.issue.labels.*.name, 'O-Frequent') || + github.repository == 'vector-im/element-android' && + (!contains(github.event.issue.labels.*.name, 'A-E2EE') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') && + !contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification') && + !contains(github.event.issue.labels.*.name, 'A-Spaces') && + !contains(github.event.issue.labels.*.name, 'A-Spaces-Settings') && + !contains(github.event.issue.labels.*.name, 'A-Subspaces')) && + (contains(github.event.issue.labels.*.name, 'T-Defect') && + contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || contains(github.event.issue.labels.*.name, 'O-Occasional')) || - contains(github.event.issue.labels.*.name, 'S-Major') && - contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'A11y') && - contains(github.event.issue.labels.*.name, 'O-Frequent')) + contains(github.event.issue.labels.*.name, 'S-Major') && + contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'A11y') && + contains(github.event.issue.labels.*.name, 'O-Frequent')) steps: - uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488 with: @@ -33,20 +35,22 @@ jobs: P1_issues_to_crypto_team_workboard: runs-on: ubuntu-latest + # Skip in forks if: > - (contains(github.event.issue.labels.*.name, 'A-E2EE') || - contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') || - contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || - contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') || - contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) && - (contains(github.event.issue.labels.*.name, 'T-Defect') && - contains(github.event.issue.labels.*.name, 'S-Critical') && - (contains(github.event.issue.labels.*.name, 'O-Frequent') || + github.repository == 'vector-im/element-android' && + (contains(github.event.issue.labels.*.name, 'A-E2EE') || + contains(github.event.issue.labels.*.name, 'A-E2EE-Cross-Signing') || + contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || + contains(github.event.issue.labels.*.name, 'A-E2EE-Key-Backup') || + contains(github.event.issue.labels.*.name, 'A-E2EE-SAS-Verification')) && + (contains(github.event.issue.labels.*.name, 'T-Defect') && + contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || contains(github.event.issue.labels.*.name, 'O-Occasional')) || - contains(github.event.issue.labels.*.name, 'S-Major') && - contains(github.event.issue.labels.*.name, 'O-Frequent') || - contains(github.event.issue.labels.*.name, 'A11y') && - contains(github.event.issue.labels.*.name, 'O-Frequent')) + contains(github.event.issue.labels.*.name, 'S-Major') && + contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'A11y') && + contains(github.event.issue.labels.*.name, 'O-Frequent')) steps: - uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488 with: diff --git a/CHANGES.md b/CHANGES.md index 19400a38ba..e0dd3298d8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,65 @@ +Changes in Element v1.3.12 (2021-12-20) +======================================= + +Bugfixes 🐛 +---------- + - Fixing emoji related crashes on android 8.1.1 and below ([#4769](https://github.com/vector-im/element-android/issues/4769)) + + +Changes in Element v1.3.11 (2021-12-17) +======================================= + +Bugfixes 🐛 +---------- + - Fixing proximity sensor still being active after a call ([#2467](https://github.com/vector-im/element-android/issues/2467)) + - Fix name and shield are truncated in the room detail screen ([#4700](https://github.com/vector-im/element-android/issues/4700)) + - Call banner: center text vertically ([#4710](https://github.com/vector-im/element-android/issues/4710)) + - Fixes unable to render messages by allowing them to render whilst the emoji library is initialising ([#4733](https://github.com/vector-im/element-android/issues/4733)) + - Fix app crash uppon long press on a reply event ([#4742](https://github.com/vector-im/element-android/issues/4742)) + - Fixes crash when launching rooms which contain emojis in the emote content on android 12+ ([#4743](https://github.com/vector-im/element-android/issues/4743)) + +Other changes +------------- + - Avoids leaking the activity windows when loading dialogs are displaying ([#4713](https://github.com/vector-im/element-android/issues/4713)) + + +Changes in Element v1.3.10 (2021-12-14) +======================================= + +Features ✨ +---------- + - Poll Feature - Render in timeline ([#4653](https://github.com/vector-im/element-android/issues/4653)) + - Updates URL previews to match latest designs ([#4278](https://github.com/vector-im/element-android/issues/4278)) + - Setup Analytics framework using PostHog. Analytics are disabled by default. Opt-in screen not automatically displayed yet. ([#4559](https://github.com/vector-im/element-android/issues/4559)) + - Create a legal screen in the setting to group all the different policies. ([#4660](https://github.com/vector-im/element-android/issues/4660)) + - Add a help section in the settings. ([#4638](https://github.com/vector-im/element-android/issues/4638)) + - MSC2732: Olm fallback keys ([#3473](https://github.com/vector-im/element-android/issues/3473)) + +Bugfixes 🐛 +---------- + - Fixes message menu showing when copying message urls ([#4324](https://github.com/vector-im/element-android/issues/4324)) + - Fix lots of integration tests by introducing TestMatrix class and MatrixWorkerFactory. ([#4546](https://github.com/vector-im/element-android/issues/4546)) + - Fix empty Dev Tools screen issue. ([#4592](https://github.com/vector-im/element-android/issues/4592)) + - Fix for outgoing voip call via sip bridge failing after 1 minute. ([#4621](https://github.com/vector-im/element-android/issues/4621)) + - Update log warning for call selection during voip calls. ([#4636](https://github.com/vector-im/element-android/issues/4636)) + - Fix possible crash when having identical subspaces in multiple root spaces ([#4693](https://github.com/vector-im/element-android/issues/4693)) + - Fix a crash in the timeline with some Emojis. Also migrate to androidx.emoji2 ([#4698](https://github.com/vector-im/element-android/issues/4698)) + - At the very first room search after opening the app sometimes no results are displayed ([#4600](https://github.com/vector-im/element-android/issues/4600)) + +Other changes +------------- + - Upgrade OLM to v3.2.7 and get it from our maven repository. ([#4647](https://github.com/vector-im/element-android/issues/4647)) + - Add explicit dependency location, regarding the several maven repository. Also update some libraries (flexbox and alerter), and do some cleanup. ([#4670](https://github.com/vector-im/element-android/issues/4670)) + - Introducing feature flagging to the login and notification settings flows ([#4626](https://github.com/vector-im/element-android/issues/4626)) + - There is no need to call job.cancel() when we are using viewModelScope() ([#4602](https://github.com/vector-im/element-android/issues/4602)) + - Debounce some clicks ([#4645](https://github.com/vector-im/element-android/issues/4645)) + - Improve issue automation workflows ([#4617](https://github.com/vector-im/element-android/issues/4617)) + - Add automation to move message bubbles issues to message bubbles board. ([#4666](https://github.com/vector-im/element-android/issues/4666)) + - Fix graphql warning in issue workflow automation ([#4671](https://github.com/vector-im/element-android/issues/4671)) + - Cleanup the layout files ([#4604](https://github.com/vector-im/element-android/issues/4604)) + - Cleanup id ref. Use type views instead ([#4650](https://github.com/vector-im/element-android/issues/4650)) + + Changes in Element v1.3.9 (2021-12-01) ====================================== diff --git a/README.md b/README.md index a085bf7da1..0345c50146 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,9 @@ If you would like to receive releases more quickly (bearing in mind that they ma Please refer to [CONTRIBUTING.md](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md) if you want to contribute on Matrix Android projects! Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/#element-android:matrix.org). + +## Triaging issues + +Issues are triaged by community members and the Android App Team, following the [triage process](https://github.com/vector-im/element-meta/wiki/Triage-process). + +We use [issue labels](https://github.com/vector-im/element-meta/wiki/Issue-labelling) to sort all incoming issues. \ No newline at end of file diff --git a/build.gradle b/build.gradle index e17f357905..255d9da849 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ buildscript { // ktlint Plugin plugins { - id "org.jlleitschuh.gradle.ktlint" version "10.2.0" + id "org.jlleitschuh.gradle.ktlint" version "10.2.1" } allprojects { diff --git a/changelog.d/2133.feature b/changelog.d/2133.feature new file mode 100644 index 0000000000..5649ca4cc6 --- /dev/null +++ b/changelog.d/2133.feature @@ -0,0 +1 @@ +Add labs support for rendering LaTeX maths (MSC2191) diff --git a/changelog.d/2614.feature b/changelog.d/2614.feature new file mode 100644 index 0000000000..7feceeabba --- /dev/null +++ b/changelog.d/2614.feature @@ -0,0 +1 @@ +Allow changing nick colors from the member detail screen \ No newline at end of file diff --git a/changelog.d/3444.feature b/changelog.d/3444.feature new file mode 100644 index 0000000000..8b9fad18b1 --- /dev/null +++ b/changelog.d/3444.feature @@ -0,0 +1 @@ +New attachment picker UI \ No newline at end of file diff --git a/changelog.d/3473.bugfix b/changelog.d/3473.bugfix deleted file mode 100644 index df3e747e44..0000000000 --- a/changelog.d/3473.bugfix +++ /dev/null @@ -1 +0,0 @@ -MSC2732: Olm fallback keys \ No newline at end of file diff --git a/changelog.d/4278.feature b/changelog.d/4278.feature deleted file mode 100644 index fe82755186..0000000000 --- a/changelog.d/4278.feature +++ /dev/null @@ -1 +0,0 @@ -Updates URL previews to match latest designs \ No newline at end of file diff --git a/changelog.d/4324.bugfix b/changelog.d/4324.bugfix deleted file mode 100644 index 8f04357344..0000000000 --- a/changelog.d/4324.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fixes message menu showing when copying message urls \ No newline at end of file diff --git a/changelog.d/4382.feature b/changelog.d/4382.feature new file mode 100644 index 0000000000..64230dccb1 --- /dev/null +++ b/changelog.d/4382.feature @@ -0,0 +1 @@ +Updates onboarding splash screen to have a dedicated sign in button and removes the dual purpose sign in/up stage \ No newline at end of file diff --git a/changelog.d/4405.feature b/changelog.d/4405.feature new file mode 100644 index 0000000000..9a840a9d12 --- /dev/null +++ b/changelog.d/4405.feature @@ -0,0 +1 @@ +Change internal timeline management. \ No newline at end of file diff --git a/changelog.d/4405.removal b/changelog.d/4405.removal new file mode 100644 index 0000000000..2d1543cb2b --- /dev/null +++ b/changelog.d/4405.removal @@ -0,0 +1 @@ +Introduce method onStateUpdated on Timeline.Callback \ No newline at end of file diff --git a/changelog.d/4546.bugfix b/changelog.d/4546.bugfix deleted file mode 100644 index a59fa6557a..0000000000 --- a/changelog.d/4546.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix lots of integration tests by introducing TestMatrix class and MatrixWorkerFactory. \ No newline at end of file diff --git a/changelog.d/4559.feature b/changelog.d/4559.feature deleted file mode 100644 index d175549f51..0000000000 --- a/changelog.d/4559.feature +++ /dev/null @@ -1 +0,0 @@ -Setup Analytics framework using PostHog. Analytics are disabled by default. Opt-in screen not automatically displayed yet. \ No newline at end of file diff --git a/changelog.d/4592.bugfix b/changelog.d/4592.bugfix deleted file mode 100644 index b27e793100..0000000000 --- a/changelog.d/4592.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix empty Dev Tools screen issue. diff --git a/changelog.d/4600.misc b/changelog.d/4600.misc deleted file mode 100644 index e877a33b29..0000000000 --- a/changelog.d/4600.misc +++ /dev/null @@ -1 +0,0 @@ -At the very first room search after opening the app sometimes no results are displayed diff --git a/changelog.d/4602.misc b/changelog.d/4602.misc deleted file mode 100644 index 7f6271242a..0000000000 --- a/changelog.d/4602.misc +++ /dev/null @@ -1 +0,0 @@ -There is no need to call job.cancel() when we are using viewModelScope() diff --git a/changelog.d/4604.misc b/changelog.d/4604.misc deleted file mode 100644 index 2a3d3f70c3..0000000000 --- a/changelog.d/4604.misc +++ /dev/null @@ -1 +0,0 @@ -Cleanup the layout files \ No newline at end of file diff --git a/changelog.d/4612.misc b/changelog.d/4612.misc new file mode 100644 index 0000000000..43b5007b7e --- /dev/null +++ b/changelog.d/4612.misc @@ -0,0 +1 @@ +Workaround to fetch all the pending toDevice events from a Synapse homeserver \ No newline at end of file diff --git a/changelog.d/4617.misc b/changelog.d/4617.misc deleted file mode 100644 index 0044511eee..0000000000 --- a/changelog.d/4617.misc +++ /dev/null @@ -1 +0,0 @@ -Improve issue automation workflows diff --git a/changelog.d/4621.bugfix b/changelog.d/4621.bugfix deleted file mode 100644 index 2bc21e8f8e..0000000000 --- a/changelog.d/4621.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix for outgoing voip call via sip bridge failing after 1 minute. \ No newline at end of file diff --git a/changelog.d/4626.misc b/changelog.d/4626.misc deleted file mode 100644 index 9f2d979cbb..0000000000 --- a/changelog.d/4626.misc +++ /dev/null @@ -1 +0,0 @@ -Introducing feature flagging to the login and notification settings flows \ No newline at end of file diff --git a/changelog.d/4636.bugfix b/changelog.d/4636.bugfix deleted file mode 100644 index ec95696a4f..0000000000 --- a/changelog.d/4636.bugfix +++ /dev/null @@ -1 +0,0 @@ -Update log warning for call selection during voip calls. \ No newline at end of file diff --git a/changelog.d/4638.feature b/changelog.d/4638.feature deleted file mode 100644 index 0f8bd36465..0000000000 --- a/changelog.d/4638.feature +++ /dev/null @@ -1 +0,0 @@ -Add a help section in the settings. \ No newline at end of file diff --git a/changelog.d/4644.misc b/changelog.d/4644.misc new file mode 100644 index 0000000000..faf3346f66 --- /dev/null +++ b/changelog.d/4644.misc @@ -0,0 +1 @@ +Toolbar is added to a views with QR code scan \ No newline at end of file diff --git a/changelog.d/4645.misc b/changelog.d/4645.misc deleted file mode 100644 index 7f96fc551e..0000000000 --- a/changelog.d/4645.misc +++ /dev/null @@ -1 +0,0 @@ -Debounce some clicks \ No newline at end of file diff --git a/changelog.d/4647.misc b/changelog.d/4647.misc deleted file mode 100644 index a33afba138..0000000000 --- a/changelog.d/4647.misc +++ /dev/null @@ -1 +0,0 @@ - Upgrade OLM to v3.2.7 and get it from our maven repository. \ No newline at end of file diff --git a/changelog.d/4650.misc b/changelog.d/4650.misc deleted file mode 100644 index 3537441d51..0000000000 --- a/changelog.d/4650.misc +++ /dev/null @@ -1 +0,0 @@ -Cleanup id ref. Use type views instead \ No newline at end of file diff --git a/changelog.d/4653.feature b/changelog.d/4653.feature deleted file mode 100644 index d53d322c0c..0000000000 --- a/changelog.d/4653.feature +++ /dev/null @@ -1 +0,0 @@ -Poll Feature - Render in timeline \ No newline at end of file diff --git a/changelog.d/4660.feature b/changelog.d/4660.feature deleted file mode 100644 index 4eca82eaf5..0000000000 --- a/changelog.d/4660.feature +++ /dev/null @@ -1 +0,0 @@ -Create a legal screen in the setting to group all the different policies. \ No newline at end of file diff --git a/changelog.d/4666.misc b/changelog.d/4666.misc deleted file mode 100644 index 9401963de5..0000000000 --- a/changelog.d/4666.misc +++ /dev/null @@ -1 +0,0 @@ -Add automation to move message bubbles issues to message bubbles board. diff --git a/changelog.d/4670.misc b/changelog.d/4670.misc deleted file mode 100644 index 6b1233145b..0000000000 --- a/changelog.d/4670.misc +++ /dev/null @@ -1 +0,0 @@ -Add explicit dependency location, regarding the several maven repository. Also update some libraries (flexbox and alerter), and do some cleanup. \ No newline at end of file diff --git a/changelog.d/4671.misc b/changelog.d/4671.misc deleted file mode 100644 index 1d2282038e..0000000000 --- a/changelog.d/4671.misc +++ /dev/null @@ -1 +0,0 @@ -Fix graphql warning in issue workflow automation diff --git a/changelog.d/4693.bugfix b/changelog.d/4693.bugfix deleted file mode 100644 index 2aca848628..0000000000 --- a/changelog.d/4693.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix possible crash when having identical subspaces in multiple root spaces diff --git a/changelog.d/4698.bugfix b/changelog.d/4698.bugfix deleted file mode 100644 index bcf20dd75f..0000000000 --- a/changelog.d/4698.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix a crash in the timeline with some Emojis. Also migrate to androidx.emoji2 \ No newline at end of file diff --git a/changelog.d/4719.feature b/changelog.d/4719.feature new file mode 100644 index 0000000000..b926613d03 --- /dev/null +++ b/changelog.d/4719.feature @@ -0,0 +1 @@ +Analytics: Track Errors \ No newline at end of file diff --git a/changelog.d/4745.misc b/changelog.d/4745.misc new file mode 100644 index 0000000000..458422d112 --- /dev/null +++ b/changelog.d/4745.misc @@ -0,0 +1 @@ +Open share UI provides by the system when sharing media or text. \ No newline at end of file diff --git a/changelog.d/4747.misc b/changelog.d/4747.misc new file mode 100644 index 0000000000..37a960671c --- /dev/null +++ b/changelog.d/4747.misc @@ -0,0 +1 @@ +Cleaning rendering of state events in timeline \ No newline at end of file diff --git a/changelog.d/4749.bugfix b/changelog.d/4749.bugfix new file mode 100644 index 0000000000..5ea29f66a0 --- /dev/null +++ b/changelog.d/4749.bugfix @@ -0,0 +1 @@ +Fix for broken unread message indicator on the room list when there are no messages in the room. \ No newline at end of file diff --git a/changelog.d/4753.removal b/changelog.d/4753.removal new file mode 100644 index 0000000000..1a474f0f5f --- /dev/null +++ b/changelog.d/4753.removal @@ -0,0 +1 @@ +Support tagged events in Room Account Data (MSC2437) diff --git a/changelog.d/4756.bugfix b/changelog.d/4756.bugfix new file mode 100644 index 0000000000..8e0c373557 --- /dev/null +++ b/changelog.d/4756.bugfix @@ -0,0 +1 @@ +Fixes newer emojis rendering strangely when inserting from the system keyboard \ No newline at end of file diff --git a/changelog.d/4767.bugfix b/changelog.d/4767.bugfix new file mode 100644 index 0000000000..172e9d80ca --- /dev/null +++ b/changelog.d/4767.bugfix @@ -0,0 +1 @@ +Fixing unable to change change avatar in some scenarios \ No newline at end of file diff --git a/changelog.d/4781.bugfix b/changelog.d/4781.bugfix new file mode 100644 index 0000000000..7ac6e62448 --- /dev/null +++ b/changelog.d/4781.bugfix @@ -0,0 +1 @@ +Tentative fix for the speaker being used instead of earpiece for the outgoing call ringtone on lineage os \ No newline at end of file diff --git a/changelog.d/4789.bugfix b/changelog.d/4789.bugfix new file mode 100644 index 0000000000..4612dd1ecd --- /dev/null +++ b/changelog.d/4789.bugfix @@ -0,0 +1 @@ +Fixing crashes when quickly scrolling or restoring the room timeline \ No newline at end of file diff --git a/changelog.d/4804.bugfix b/changelog.d/4804.bugfix new file mode 100644 index 0000000000..8f845662ab --- /dev/null +++ b/changelog.d/4804.bugfix @@ -0,0 +1 @@ +Fixing encrypted non message events showing up as notification messages (eg when a participant joins, mutes or leaves a voice call) \ No newline at end of file diff --git a/changelog.d/4837.bugfix b/changelog.d/4837.bugfix new file mode 100644 index 0000000000..d1eae295f5 --- /dev/null +++ b/changelog.d/4837.bugfix @@ -0,0 +1 @@ +Stop using CharSequence as EpoxyAttribute because it can lead to crash if the CharSequence mutates during rendering. \ No newline at end of file diff --git a/changelog.d/4847.bugfix b/changelog.d/4847.bugfix new file mode 100644 index 0000000000..d311882934 --- /dev/null +++ b/changelog.d/4847.bugfix @@ -0,0 +1 @@ +Translate the error observed when the user is not allowed to join a room \ No newline at end of file diff --git a/changelog.d/4864.misc b/changelog.d/4864.misc new file mode 100644 index 0000000000..05890ae1c6 --- /dev/null +++ b/changelog.d/4864.misc @@ -0,0 +1 @@ +Fix github actions ktlint reports and publish results on PR as comment diff --git a/changelog.d/4872.misc b/changelog.d/4872.misc new file mode 100644 index 0000000000..6a29e55494 --- /dev/null +++ b/changelog.d/4872.misc @@ -0,0 +1 @@ +Enabling new FTUE Auth onboarding base, includes the "I already have an account" button in the splash \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle index 4a076a23bd..6cb5fac64c 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -95,6 +95,8 @@ ext.libs = [ ], markwon : [ 'core' : "io.noties.markwon:core:$markwon", + 'extLatex' : "io.noties.markwon:ext-latex:$markwon", + 'inlineParser' : "io.noties.markwon:inline-parser:$markwon", 'html' : "io.noties.markwon:html:$markwon" ], airbnb : [ diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index 25a78bc0c3..7edf54fb50 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -179,6 +179,7 @@ ext.groups = [ 'org.sonatype.oss', 'org.testng', 'org.threeten', + 'ru.noties', 'xerces', 'xml-apis', ] diff --git a/docs/design.md b/docs/design.md index 2e27f00ebf..a79f19cf3e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -50,6 +50,17 @@ It's also possible for any icon to go to the main component by right-clicking on - open the created vector drawable - optionally update the color(s) to "#FF0000" (red) to ensure that the drawable is correctly tinted at runtime. +### Images + +Android 4.3 (18+) fully supports the WebP image format which can often provide smaller image sizes without drastically impacting image quality (depending on the output encoding quality). +When importing non vector images, WebP is the preferred format. + +Images can be converted to the WebP within Android Studio by + - right clicking the image file within the project file explorer + - select `Convert to WebP` + +https://developer.android.com/studio/write/convert-webp + ## Figma links Figma links can be included in the layout, for future reference, but it is also OK to add a paragraph below here, to centralize the information diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40103090.txt b/fastlane/metadata/android/cs-CZ/changelogs/40103090.txt new file mode 100644 index 0000000000..fe61a48d12 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: Přidání podpory pro návrh hlasové zprávy. Opravy mnoha chyb! +Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103050.txt b/fastlane/metadata/android/de-DE/changelogs/40103050.txt new file mode 100644 index 0000000000..a3e40e9e03 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103050.txt @@ -0,0 +1,2 @@ +Änderungen in dieser Version: Unterstützung für Anwesenheitsstatus in Direktnachrichten (Momentan auf matrix.org deaktiviert), Android Auto funktioniert wieder. +Änderungsliste: https://github.com/vector-im/element-android/releases/tag/v1.3.5 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103060.txt b/fastlane/metadata/android/de-DE/changelogs/40103060.txt new file mode 100644 index 0000000000..dcd8d3634d --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103060.txt @@ -0,0 +1,2 @@ +Änderungen in dieser Version: Unterstützung für Anwesenheitsstatus in Direktnachrichten (Momentan auf matrix.org deaktiviert), Android Auto funktioniert wieder. +Änderungsliste: https://github.com/vector-im/element-android/releases/tag/v1.3.6 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103090.txt b/fastlane/metadata/android/de-DE/changelogs/40103090.txt new file mode 100644 index 0000000000..028df4942f --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Hauptänderungen: Verbesserungen bei Sprachnachrichten, Bugfixes. +Änderungsliste: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/en-US/changelogs/40103100.txt b/fastlane/metadata/android/en-US/changelogs/40103100.txt new file mode 100644 index 0000000000..d3e7f6eb4a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40103100.txt @@ -0,0 +1,2 @@ +Main changes in this version: Add support for polls (in labs). New URL preview design. +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.10 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40103110.txt b/fastlane/metadata/android/en-US/changelogs/40103110.txt new file mode 100644 index 0000000000..c28b303a35 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40103110.txt @@ -0,0 +1,2 @@ +Main changes in this version: Bug fixes! +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.11 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40103120.txt b/fastlane/metadata/android/en-US/changelogs/40103120.txt new file mode 100644 index 0000000000..90d55f5f48 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40103120.txt @@ -0,0 +1,2 @@ +Main changes in this version: Bug fixes! +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.12 \ No newline at end of file diff --git a/fastlane/metadata/android/et/changelogs/40103090.txt b/fastlane/metadata/android/et/changelogs/40103090.txt new file mode 100644 index 0000000000..e931ba5386 --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: Häälsõnumite võimalus. Palju veaparandusi! +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/fa/changelogs/40103090.txt b/fastlane/metadata/android/fa/changelogs/40103090.txt new file mode 100644 index 0000000000..75810a0e23 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40103090.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارش: افزودن پشتیبان از چرک‌نویس‌های صوتی. رفع چندین مشکل! +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/fr-FR/changelogs/40103090.txt b/fastlane/metadata/android/fr-FR/changelogs/40103090.txt new file mode 100644 index 0000000000..3394e5ccfa --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : Ajout du support pour les brouillons de messages vocaux. Beaucoup de corrections de bugs ! +Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/hu-HU/changelogs/40103090.txt b/fastlane/metadata/android/hu-HU/changelogs/40103090.txt new file mode 100644 index 0000000000..d4189121bb --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Fő változás ebben a verzióban: Hang üzenet piszkozat támogatás. Sok egyéb hibajavítás. +Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/id/changelogs/40103090.txt b/fastlane/metadata/android/id/changelogs/40103090.txt new file mode 100644 index 0000000000..b371ba9fab --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Perubahan utama di versi ini: Tambahkan dukungan untuk draf pesan suara. Banyak perbaikan bug! +Changelog lengkap: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/id/short_description.txt b/fastlane/metadata/android/id/short_description.txt index 1cd770dd73..72c520403c 100644 --- a/fastlane/metadata/android/id/short_description.txt +++ b/fastlane/metadata/android/id/short_description.txt @@ -1 +1 @@ -Perpesanan grup - perpesanan, panggilan suara dan video grup terenkripsi +Perpesanan grup — perpesanan, panggilan suara dan video grup terenkripsi diff --git a/fastlane/metadata/android/id/title.txt b/fastlane/metadata/android/id/title.txt index aec5dc9351..08ad7afa67 100644 --- a/fastlane/metadata/android/id/title.txt +++ b/fastlane/metadata/android/id/title.txt @@ -1 +1 @@ -Element - Perpesanan Aman +Element — Perpesanan Aman diff --git a/fastlane/metadata/android/it-IT/changelogs/40103090.txt b/fastlane/metadata/android/it-IT/changelogs/40103090.txt new file mode 100644 index 0000000000..d91ecfe530 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: aggiunto supporto per le bozze dei vocali. Molte correzioni! +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/pt-BR/changelogs/40103090.txt b/fastlane/metadata/android/pt-BR/changelogs/40103090.txt new file mode 100644 index 0000000000..9f67fd2d62 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Principais mudanças nesta versão: Adicionar suporte para rascunho de mensagem de voz. Muitos consertos de bugs! +Changelog completo: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/sk/changelogs/40103060.txt b/fastlane/metadata/android/sk/changelogs/40103060.txt new file mode 100644 index 0000000000..c9a3b8bb75 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103060.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Pridanie podpory prítomnosti pre miestnosť s priamymi správami (poznámka: prítomnosť je na matrix.org vypnutá). Opätovné pridanie podpory Android Auto. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.3.6 diff --git a/fastlane/metadata/android/sk/changelogs/40103090.txt b/fastlane/metadata/android/sk/changelogs/40103090.txt new file mode 100644 index 0000000000..d719d5055c --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Pridanie podpory pre návrh hlasovej správy. Oprava mnohých chýb! +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/sk/full_description.txt b/fastlane/metadata/android/sk/full_description.txt index b4c9e98777..78661e961e 100644 --- a/fastlane/metadata/android/sk/full_description.txt +++ b/fastlane/metadata/android/sk/full_description.txt @@ -1,30 +1,41 @@ -Element je inovatívny kolaboračný komunikátor a messenger ktorý: +Element je zabezpečený messenger a zároveň aplikácia na tímovú spoluprácu, ktorá je ideálna na skupinové konverzácie pri práci na diaľku. Táto komunikačná aplikácia využíva end-to-end šifrovanie na poskytovanie výkonných videokonferencií, zdieľania súborov a hlasových hovorov. -1. Ponecháva kontrolu nad vaším súkromím -2. Umožňuje komunikovať s kýmkoľvek v sieti Matrix a vďaka integráciám aj s rôznymi inými aplikáciami ako napríklad Slack -3. Chráni vás pred reklamami, zhromažďovaním údajov a uzavretými platformami -4. Posilňuje vašu bezpečnosť vďaka E2E šifrovaniu a krížovému podpisovaniu určenému na overovanie ostatných +Funkcie aplikácie Element zahŕňajú: +- Pokročilé nástroje na online komunikáciu +- Plne šifrované správy umožňujúce bezpečnejšiu firemnú komunikáciu aj pre pracovníkov na diaľku +- Decentralizované konverzácie založené na open source frameworku Matrix +- Bezpečné zdieľanie súborov so šifrovanými údajmi pri správe projektov +- Videochaty s funkciou Voice over IP a zdieľaním obrazovky +- Jednoduchá integrácia s obľúbenými nástrojmi na online spoluprácu, nástrojmi na riadenie projektov, službami VoIP a inými aplikáciami na tímovú komunikáciu -Element sa od ostatných komunikačných a kolaboračných aplikácií odlišuje tým, že je decentralizovaný a open-source. +Element sa úplne líši od ostatných aplikácií na zasielanie správ a spoluprácu. Funguje na Matrixe, otvorenej sieti na bezpečné posielanie správ a decentralizovanú komunikáciu. Umožňuje vlastný hosting, aby používatelia získali maximálne vlastníctvo a kontrolu nad svojimi údajmi a správami. -S Elementom sa môžete pripojiť k vlastnému serveru alebo si môžete vybrať server s dôveryhodným poskytovateľom, čím si zachováte súkromie, vlastníctvo a kontrolu nad vašimi konverzáciami a údajmi. Získate tak prístup do otvorenej siete a teda nie ste limitovaní na komunikáciu len s ostatnými Element používateľmi. A samozrejme je vaša komunikácia dobre zabezpečná. +Súkromie a šifrovanie správ +Element vás chráni pred nežiaducimi reklamami, ťažbou údajov a tzv. walled gardens. Zabezpečuje tiež všetky vaše údaje, video a hlasovú komunikáciu jeden na jedného prostredníctvom end-to-end šifrovania a overovania zariadení krížovým podpisovaním +Element vám poskytuje kontrolu nad vaším súkromím a zároveň vám umožňuje bezpečne komunikovať s kýmkoľvek v sieti Matrix alebo s inými nástrojmi na podnikovú spoluprácu vďaka integrácii s aplikáciami, ako je napríklad Slack. -Element všetko toto dokáže vďaka tomu, že pracuje podľa protokolu Matrix - štandardu na otvorenú, decentralizovanú komunikáciu. +Element môže byť na vašom vlastnom serveri. +Aby ste mali väčšiu kontrolu nad svojimi citlivými údajmi a konverzáciami, Element môže byť na vašom vlastnom serveri alebo si môžete vybrať ľubovoľný hosting založený na systéme Matrix - štandarde pre decentralizovanú komunikáciu s otvoreným zdrojovým kódom. Element vám poskytuje súkromie, súlad s bezpečnostnými predpismi a flexibilitu integrácie. -Element vám dáva kontrolu tým, že si samy vyberiete, ako budete spravovať (ang. host) vaše konverzácie. Priamo v aplikácii Element si môžete vybrať z rôznych spôsobov hostovania: +Vlastnite svoje údaje +Vy rozhodujete o tom, kde budú vaše údaje a správy uložené. Bez rizika ťažby údajov alebo prístupu tretích strán. -1. Získajte účet zdarma na verejnom servery matrix.org od vývojárov protokolu Matrix alebo si vyberte z tísíce iných serverov hostovaných dobrovoľníkmi -2. Hostujte si účet spustením vlastného servera použitím vlastného hardvéru -3. Prihláste sa k účtu na vlastnom servery objednaním služieb na platforme Element Matrix Services +Element vám dáva kontrolu rôznymi spôsobmi: +1. Získajte bezplatné konto na verejnom serveri matrix.org, ktorý hostia vývojári Matrixu, alebo si vyberte z tisícov verejných serverov, ktoré hostia dobrovoľníci. +2. Vlastný hosting účtu spustením servera na vlastnej IT infraštruktúre. +3. Zaregistrujte si účet na vlastnom serveri tak, že si jednoducho predplatíte hostingovú platformu Element Matrix Services. -Prečo si vybrať Element? +Otvorené zasielanie správ a spolupráca +Môžete komunikovať s kýmkoľvek v sieti Matrix, či už používa aplikáciu Element, inú aplikáciu Matrix alebo dokonca ak používa inú aplikáciu na zasielanie správ. -PONECHAJTE SI VAŠE ÚDAJE: Len vy rozhodujete o tom, kde si budete uchovávať vaše správy a ostatné údaje. Len vy vlastníte vaše údaje a riadite zaobchádzanie s nimi, nie nejaká megakorporácia, ktorá z nich ťaží alebo ich poskytuje tretím stranám. +Vynikajúce zabezpečenie +Skutočné end-to-end šifrovanie (správy môžu dešifrovať len účastníci konverzácie) a krížové overovanie zariadení. -OTVORENÁ KOMUNIKÁCIA a KOLABORÁCIA: Konverzovať môžete s kýmkoľvek v otvorenej sieti Matrix nezávisle na tom, či používa Element, inú kompatibilnú aplikáciu, ba dokkonca aj s tými, ktorí používajú úplne inú platformu určenú na okamžitú komunikáciu ako sú Slack, IRC alebo XMPP. +Kompletná komunikácia a integrácia +Správy, hlasové a video hovory, zdieľanie súborov, zdieľanie obrazovky a celý rad integrácií, botov a widgetov. Vytvárajte miestnosti, komunity, zostaňte v kontakte a vybavujte veci. -VEĽMI VYSOKÉ ZABEZPEČENIE: Skutočné šifrovanie od zariadenia k zariadeniu (len diskutujúci môžu dešifrovať správy) a krížové podpisovanie určené na overovanie jednotlivých zariadení členov konverzácií. +Nadviažte tam, kde ste skončili +Buďte v kontakte, nech ste kdekoľvek, vďaka plne synchronizovanej histórii správ vo všetkých zariadeniach a na webe na adrese https://app.element.io. -KOMPLETNÁ KOMUNIKÁCIA: Okamžité správy, telefonáty a video hovory, zdieľanie súborov, zdieľanie obrazovky a veľké množstvo integrácií, botov a widgetov. Vytvorte si vlastné miestnosti, založte komunity, ostante v kontakte a vyriešte problémy. - -KDEKOĽVEK SA NACHÁDZATE: Ostante v kontakte kdekoľvek ste s plne synchronizovanou históriou konverzácií naprieč všetkými vašimi zariadeniami a aj cez web na adrese https://app.element.io. +Otvorený zdroj +Element Android je projekt s otvoreným zdrojovým kódom, ktorého hostiteľom je GitHub. Nahlasujte chyby a/alebo prispievajte k jeho vývoju na adrese https://github.com/vector-im/element-android. diff --git a/fastlane/metadata/android/sk/title.txt b/fastlane/metadata/android/sk/title.txt index dd02c784e8..fa7155e82e 100644 --- a/fastlane/metadata/android/sk/title.txt +++ b/fastlane/metadata/android/sk/title.txt @@ -1 +1 @@ -Element (kedysi Riot.im) +Element - Bezpečný messenger diff --git a/fastlane/metadata/android/sq/changelogs/40103090.txt b/fastlane/metadata/android/sq/changelogs/40103090.txt new file mode 100644 index 0000000000..2dae814fc1 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Shtim mbulimi për skica mesazhesh zanore. Mjaft ndreqje të metash! +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/sv-SE/changelogs/40103090.txt b/fastlane/metadata/android/sv-SE/changelogs/40103090.txt new file mode 100644 index 0000000000..dce7ffe5a7 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: Lägg till stöd för röstmeddelandeutkast. Många buggfixar! +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/uk/changelogs/40103090.txt b/fastlane/metadata/android/uk/changelogs/40103090.txt new file mode 100644 index 0000000000..37f8959d4c --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Основні зміни в цій версії: підтримка чернеток голосових повідомлень. Багато виправлень помилок! +Повний журнал змін: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/zh-CN/changelogs/40103090.txt b/fastlane/metadata/android/zh-CN/changelogs/40103090.txt new file mode 100644 index 0000000000..7eb68d61e4 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/40103090.txt @@ -0,0 +1,2 @@ +版本的主要变化:增加了对语音信息草稿的支持。许多修正! +完整更新日志:https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/zh-TW/changelogs/40103090.txt b/fastlane/metadata/android/zh-TW/changelogs/40103090.txt new file mode 100644 index 0000000000..c74a27acbf --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40103090.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:新增對語音訊息草稿的支援。許多臭蟲修復! +完整的變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cf2b23094e..ee6ba9a3ac 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b75392c5625a88bccd58a574552a5a323edca82dab5942d2d41097f809c6bcce -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-all.zip +distributionSha256Sum=c9490e938b221daf0094982288e4038deed954a3f12fb54cbf270ddf4e37d879 +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/library/ui-styles/src/main/res/drawable/bg_carousel_page_1.xml b/library/ui-styles/src/main/res/drawable/bg_carousel_page_1.xml new file mode 100644 index 0000000000..bff828fb22 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/bg_carousel_page_1.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/drawable/bg_carousel_page_2.xml b/library/ui-styles/src/main/res/drawable/bg_carousel_page_2.xml new file mode 100644 index 0000000000..54e5286ded --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/bg_carousel_page_2.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/drawable/bg_carousel_page_3.xml b/library/ui-styles/src/main/res/drawable/bg_carousel_page_3.xml new file mode 100644 index 0000000000..c31c70c078 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/bg_carousel_page_3.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/drawable/bg_carousel_page_4.xml b/library/ui-styles/src/main/res/drawable/bg_carousel_page_4.xml new file mode 100644 index 0000000000..56989688af --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/bg_carousel_page_4.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/debug/res/drawable/ic_debug_icon.xml b/library/ui-styles/src/main/res/drawable/ic_debug_icon.xml similarity index 100% rename from library/ui-styles/src/debug/res/drawable/ic_debug_icon.xml rename to library/ui-styles/src/main/res/drawable/ic_debug_icon.xml diff --git a/library/ui-styles/src/main/res/values-sw600dp/dimens.xml b/library/ui-styles/src/main/res/values-sw600dp/dimens.xml index 204d663d9c..f399a350b1 100644 --- a/library/ui-styles/src/main/res/values-sw600dp/dimens.xml +++ b/library/ui-styles/src/main/res/values-sw600dp/dimens.xml @@ -2,4 +2,8 @@ 400dp + + + 0.25 + 0.75 \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 864f3d3d7f..a2a6b34b0f 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -42,4 +42,13 @@ 8dp + + + 56dp + 52dp + 1dp + + + 0.05 + 0.95 \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/stylable_pool_result_line.xml b/library/ui-styles/src/main/res/values/stylable_pool_result_line.xml deleted file mode 100644 index 93e9851106..0000000000 --- a/library/ui-styles/src/main/res/values/stylable_pool_result_line.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt index 2a0abd3d24..669e27edfd 100644 --- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt +++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt @@ -152,6 +152,13 @@ class FlowSession(private val session: Session) { } } + fun liveUserAccountData(type: String): Flow> { + return session.accountDataService().getLiveUserAccountDataEvent(type).asFlow() + .startWith(session.coroutineDispatchers.io) { + session.accountDataService().getUserAccountDataEvent(type).toOptional() + } + } + fun liveRoomAccountData(types: Set): Flow> { return session.accountDataService().getLiveRoomAccountDataEvents(types).asFlow() .startWith(session.coroutineDispatchers.io) { diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 477f971e04..44b002697d 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -31,7 +31,7 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' - buildConfigField "String", "SDK_VERSION", "\"1.3.10\"" + buildConfigField "String", "SDK_VERSION", "\"1.3.13\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" @@ -158,7 +158,7 @@ dependencies { implementation libs.apache.commonsImaging // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.39' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.40' testImplementation libs.tests.junit testImplementation 'org.robolectric:robolectric:4.7.3' diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/LiveDataTestObserver.java b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/LiveDataTestObserver.java index 26920fbb35..18de66e69e 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/LiveDataTestObserver.java +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/LiveDataTestObserver.java @@ -208,4 +208,4 @@ public final class LiveDataTestObserver implements Observer { liveData.observeForever(observer); return observer; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt index 8e21828562..3cb699378f 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt @@ -145,36 +145,9 @@ class CommonTestHelper(context: Context) { * @param nbOfMessages the number of time the message will be sent */ fun sendTextMessage(room: Room, message: String, nbOfMessages: Int, timeout: Long = TestConstants.timeOutMillis): List { - val sentEvents = ArrayList(nbOfMessages) val timeline = room.createTimeline(null, TimelineSettings(10)) timeline.start() - waitWithLatch(timeout + 1_000L * nbOfMessages) { latch -> - val timelineListener = object : Timeline.Listener { - override fun onTimelineFailure(throwable: Throwable) { - } - - override fun onNewTimelineEvents(eventIds: List) { - // noop - } - - override fun onTimelineUpdated(snapshot: List) { - val newMessages = snapshot - .filter { it.root.sendState == SendState.SYNCED } - .filter { it.root.getClearType() == EventType.MESSAGE } - .filter { it.root.getClearContent().toModel()?.body?.startsWith(message) == true } - - Timber.v("New synced message size: ${newMessages.size}") - if (newMessages.size == nbOfMessages) { - sentEvents.addAll(newMessages) - // Remove listener now, if not at the next update sendEvents could change - timeline.removeListener(this) - latch.countDown() - } - } - } - timeline.addListener(timelineListener) - sendTextMessagesBatched(room, message, nbOfMessages) - } + val sentEvents = sendTextMessagesBatched(timeline, room, message, nbOfMessages, timeout) timeline.dispose() // Check that all events has been created assertEquals("Message number do not match $sentEvents", nbOfMessages.toLong(), sentEvents.size.toLong()) @@ -182,9 +155,10 @@ class CommonTestHelper(context: Context) { } /** - * Will send nb of messages provided by count parameter but waits a bit every 10 messages to avoid gap in sync + * Will send nb of messages provided by count parameter but waits every 10 messages to avoid gap in sync */ - private fun sendTextMessagesBatched(room: Room, message: String, count: Int) { + private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long): List { + val sentEvents = ArrayList(count) (1 until count + 1) .map { "$message #$it" } .chunked(10) @@ -192,8 +166,34 @@ class CommonTestHelper(context: Context) { batchedMessages.forEach { formattedMessage -> room.sendTextMessage(formattedMessage) } - Thread.sleep(1_000L) + waitWithLatch(timeout) { latch -> + val timelineListener = object : Timeline.Listener { + + override fun onTimelineUpdated(snapshot: List) { + val allSentMessages = snapshot + .filter { it.root.sendState == SendState.SYNCED } + .filter { it.root.getClearType() == EventType.MESSAGE } + .filter { it.root.getClearContent().toModel()?.body?.startsWith(message) == true } + + val hasSyncedAllBatchedMessages = allSentMessages + .map { + it.root.getClearContent().toModel()?.body + } + .containsAll(batchedMessages) + + if (allSentMessages.size == count) { + sentEvents.addAll(allSentMessages) + } + if (hasSyncedAllBatchedMessages) { + timeline.removeListener(this) + latch.countDown() + } + } + } + timeline.addListener(timelineListener) + } } + return sentEvents } // PRIVATE METHODS ***************************************************************************** @@ -332,13 +332,6 @@ class CommonTestHelper(context: Context) { fun createEventListener(latch: CountDownLatch, predicate: (List) -> Boolean): Timeline.Listener { return object : Timeline.Listener { - override fun onTimelineFailure(throwable: Throwable) { - // noop - } - - override fun onNewTimelineEvents(eventIds: List) { - // noop - } override fun onTimelineUpdated(snapshot: List) { if (predicate(snapshot)) { diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt index ccea6f53b9..71796192a8 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt @@ -246,8 +246,7 @@ class CryptoTestHelper(private val testHelper: CommonTestHelper) { val bobRoomSummariesLive = bob.getRoomSummariesLive(roomSummaryQueryParams { }) val newRoomObserver = object : Observer> { override fun onChanged(t: List?) { - val indexOfFirst = t?.indexOfFirst { it.roomId == roomId } ?: -1 - if (indexOfFirst != -1) { + if (t?.any { it.roomId == roomId }.orFalse()) { bobRoomSummariesLive.removeObserver(this) latch.countDown() } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineBackToPreviousLastForwardTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineBackToPreviousLastForwardTest.kt deleted file mode 100644 index 7628f287c9..0000000000 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineBackToPreviousLastForwardTest.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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.shouldBeFalse -import org.amshove.kluent.shouldBeTrue -import org.junit.Assert.assertTrue -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.extensions.orFalse -import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.model.message.MessageContent -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 org.matrix.android.sdk.common.checkSendOrder -import timber.log.Timber -import java.util.concurrent.CountDownLatch - -@RunWith(JUnit4::class) -@FixMethodOrder(MethodSorters.JVM) -class TimelineBackToPreviousLastForwardTest : InstrumentedTest { - - private val commonTestHelper = CommonTestHelper(context()) - private val cryptoTestHelper = CryptoTestHelper(commonTestHelper) - - /** - * This test ensure that if we have a chunk in the timeline which is due to a sync, and we click to permalink of an - * even contained in a previous lastForward chunk, we will be able to go back to the live - */ - @Test - fun backToPreviousLastForwardTest() { - val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) - - val aliceSession = cryptoTestData.firstSession - val bobSession = cryptoTestData.secondSession!! - val aliceRoomId = cryptoTestData.roomId - - aliceSession.cryptoService().setWarnOnUnknownDevices(false) - bobSession.cryptoService().setWarnOnUnknownDevices(false) - - val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! - val roomFromBobPOV = bobSession.getRoom(aliceRoomId)!! - - val bobTimeline = roomFromBobPOV.createTimeline(null, TimelineSettings(30)) - bobTimeline.start() - - var roomCreationEventId: String? = null - - run { - val lock = CountDownLatch(1) - val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> - Timber.e("Bob timeline updated: with ${snapshot.size} events:") - snapshot.forEach { - Timber.w(" event ${it.root}") - } - - roomCreationEventId = snapshot.lastOrNull()?.root?.eventId - // Ok, we have the 8 first messages of the initial sync (room creation and bob join event) - snapshot.size == 8 - } - - bobTimeline.addListener(eventsListener) - commonTestHelper.await(lock) - bobTimeline.removeAllListeners() - - bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() - bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() - } - - // Bob stop to sync - bobSession.stopSync() - - val messageRoot = "First messages from Alice" - - // Alice sends 30 messages - commonTestHelper.sendTextMessage( - roomFromAlicePOV, - messageRoot, - 30) - - // Bob start to sync - bobSession.startSync(true) - - run { - val lock = CountDownLatch(1) - val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> - Timber.e("Bob timeline updated: with ${snapshot.size} events:") - snapshot.forEach { - Timber.w(" event ${it.root}") - } - - // Ok, we have the 10 last messages from Alice. - snapshot.size == 10 && - snapshot.all { it.root.content.toModel()?.body?.startsWith(messageRoot).orFalse() } - } - - bobTimeline.addListener(eventsListener) - commonTestHelper.await(lock) - bobTimeline.removeAllListeners() - - bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeTrue() - bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() - } - - // Bob navigate to the first event (room creation event), so inside the previous last forward chunk - run { - val lock = CountDownLatch(1) - val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> - Timber.e("Bob timeline updated: with ${snapshot.size} events:") - snapshot.forEach { - Timber.w(" event ${it.root}") - } - - // The event is in db, so it is fetch and auto pagination occurs, half of the number of events we have for this chunk (?) - snapshot.size == 4 - } - - bobTimeline.addListener(eventsListener) - - // Restart the timeline to the first sent event, which is already in the database, so pagination should start automatically - assertTrue(roomFromBobPOV.getTimeLineEvent(roomCreationEventId!!) != null) - - bobTimeline.restartWithEventId(roomCreationEventId) - - commonTestHelper.await(lock) - bobTimeline.removeAllListeners() - - bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeTrue() - bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() - } - - // Bob scroll to the future - run { - val lock = CountDownLatch(1) - val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> - Timber.e("Bob timeline updated: with ${snapshot.size} events:") - snapshot.forEach { - Timber.w(" event ${it.root}") - } - - // Bob can see the first event of the room (so Back pagination has worked) - snapshot.lastOrNull()?.root?.getClearType() == EventType.STATE_ROOM_CREATE && - // 8 for room creation item, and 30 for the forward pagination - snapshot.size == 38 && - snapshot.checkSendOrder(messageRoot, 30, 0) - } - - bobTimeline.addListener(eventsListener) - - bobTimeline.paginate(Timeline.Direction.FORWARDS, 50) - - commonTestHelper.await(lock) - bobTimeline.removeAllListeners() - - bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() - bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() - } - bobTimeline.dispose() - - cryptoTestData.cleanUp(commonTestHelper) - } -} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt index bc9722c922..05a43de0ac 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt @@ -16,6 +16,8 @@ package org.matrix.android.sdk.session.room.timeline +import kotlinx.coroutines.runBlocking +import org.amshove.kluent.internal.assertEquals import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue import org.junit.FixMethodOrder @@ -123,54 +125,29 @@ class TimelineForwardPaginationTest : InstrumentedTest { // Alice paginates BACKWARD and FORWARD of 50 events each // Then she can only navigate FORWARD run { - val lock = CountDownLatch(1) - val aliceEventsListener = commonTestHelper.createEventListener(lock) { snapshot -> - Timber.e("Alice timeline updated: with ${snapshot.size} events:") - snapshot.forEach { - Timber.w(" event ${it.root.content}") - } - - // Alice can see the first event of the room (so Back pagination has worked) - snapshot.lastOrNull()?.root?.getClearType() == EventType.STATE_ROOM_CREATE && - // 6 for room creation item (backward pagination), 1 for the context, and 50 for the forward pagination - snapshot.size == 57 // 6 + 1 + 50 + val snapshot = runBlocking { + aliceTimeline.awaitPaginate(Timeline.Direction.BACKWARDS, 50) + aliceTimeline.awaitPaginate(Timeline.Direction.FORWARDS, 50) } - - aliceTimeline.addListener(aliceEventsListener) - - // Restart the timeline to the first sent event - // We ask to load event backward and forward - aliceTimeline.paginate(Timeline.Direction.BACKWARDS, 50) - aliceTimeline.paginate(Timeline.Direction.FORWARDS, 50) - - commonTestHelper.await(lock) - aliceTimeline.removeAllListeners() - aliceTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeTrue() aliceTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS).shouldBeFalse() + + assertEquals(EventType.STATE_ROOM_CREATE, snapshot.lastOrNull()?.root?.getClearType()) + // 6 for room creation item (backward pagination), 1 for the context, and 50 for the forward pagination + // 6 + 1 + 50 + assertEquals(57, snapshot.size) } // Alice paginates once again FORWARD for 50 events // All the timeline is retrieved, she cannot paginate anymore in both direction run { - val lock = CountDownLatch(1) - val aliceEventsListener = commonTestHelper.createEventListener(lock) { snapshot -> - Timber.e("Alice timeline updated: with ${snapshot.size} events:") - snapshot.forEach { - Timber.w(" event ${it.root.content}") - } - // 6 for room creation item (backward pagination),and numberOfMessagesToSend (all the message of the room) - snapshot.size == 6 + numberOfMessagesToSend && - snapshot.checkSendOrder(message, numberOfMessagesToSend, 0) - } - - aliceTimeline.addListener(aliceEventsListener) - // Ask for a forward pagination - aliceTimeline.paginate(Timeline.Direction.FORWARDS, 50) - - commonTestHelper.await(lock) - aliceTimeline.removeAllListeners() + val snapshot = runBlocking { + aliceTimeline.awaitPaginate(Timeline.Direction.FORWARDS, 50) + } + // 6 for room creation item (backward pagination),and numberOfMessagesToSend (all the message of the room) + snapshot.size == 6 + numberOfMessagesToSend && + snapshot.checkSendOrder(message, numberOfMessagesToSend, 0) // The timeline is fully loaded aliceTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS).shouldBeFalse() diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt index e865fe17da..c6fdec150d 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt @@ -168,10 +168,8 @@ class TimelinePreviousLastForwardTest : InstrumentedTest { bobTimeline.addListener(eventsListener) - // Restart the timeline to the first sent event, and paginate in both direction + // Restart the timeline to the first sent event bobTimeline.restartWithEventId(firstMessageFromAliceId) - bobTimeline.paginate(Timeline.Direction.BACKWARDS, 50) - bobTimeline.paginate(Timeline.Direction.FORWARDS, 50) commonTestHelper.await(lock) bobTimeline.removeAllListeners() diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineSimpleBackPaginationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineSimpleBackPaginationTest.kt new file mode 100644 index 0000000000..b75df9b5a2 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineSimpleBackPaginationTest.kt @@ -0,0 +1,104 @@ +/* + * 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 kotlinx.coroutines.runBlocking +import org.amshove.kluent.internal.assertEquals +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.extensions.orFalse +import org.matrix.android.sdk.api.session.events.model.isTextMessage +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +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 org.matrix.android.sdk.common.TestConstants + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class TimelineSimpleBackPaginationTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + private val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + + @Test + fun timeline_backPaginate_shouldReachEndOfTimeline() { + val numberOfMessagesToSent = 200 + + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val bobSession = cryptoTestData.secondSession!! + val roomId = cryptoTestData.roomId + + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + bobSession.cryptoService().setWarnOnUnknownDevices(false) + + val roomFromAlicePOV = aliceSession.getRoom(roomId)!! + val roomFromBobPOV = bobSession.getRoom(roomId)!! + + // Alice sends X messages + val message = "Message from Alice" + commonTestHelper.sendTextMessage( + roomFromAlicePOV, + message, + numberOfMessagesToSent) + + val bobTimeline = roomFromBobPOV.createTimeline(null, TimelineSettings(30)) + bobTimeline.start() + + commonTestHelper.waitWithLatch(timeout = TestConstants.timeOutMillis * 10) { + val listener = object : Timeline.Listener { + + override fun onStateUpdated(direction: Timeline.Direction, state: Timeline.PaginationState) { + if (direction == Timeline.Direction.FORWARDS) { + return + } + if (state.hasMoreToLoad && !state.loading) { + bobTimeline.paginate(Timeline.Direction.BACKWARDS, 30) + } else if (!state.hasMoreToLoad) { + bobTimeline.removeListener(this) + it.countDown() + } + } + } + bobTimeline.addListener(listener) + bobTimeline.paginate(Timeline.Direction.BACKWARDS, 30) + } + assertEquals(false, bobTimeline.hasMoreToLoad(Timeline.Direction.FORWARDS)) + assertEquals(false, bobTimeline.hasMoreToLoad(Timeline.Direction.BACKWARDS)) + + val onlySentEvents = runBlocking { + bobTimeline.getSnapshot() + } + .filter { + it.root.isTextMessage() + }.filter { + (it.root.content.toModel())?.body?.startsWith(message).orFalse() + } + assertEquals(numberOfMessagesToSent, onlySentEvents.size) + + bobTimeline.dispose() + cryptoTestData.cleanUp(commonTestHelper) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineTest.kt deleted file mode 100644 index 9be0a5d5af..0000000000 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 com.zhuinden.monarchy.Monarchy -import org.matrix.android.sdk.InstrumentedTest - -internal class TimelineTest : InstrumentedTest { - - companion object { - private const val ROOM_ID = "roomId" - } - - private lateinit var monarchy: Monarchy - -// @Before -// fun setup() { -// Timber.plant(Timber.DebugTree()) -// Realm.init(context()) -// val testConfiguration = RealmConfiguration.Builder().name("test-realm") -// .modules(SessionRealmModule()).build() -// -// Realm.deleteRealm(testConfiguration) -// monarchy = Monarchy.Builder().setRealmConfiguration(testConfiguration).build() -// RoomDataHelper.fakeInitialSync(monarchy, ROOM_ID) -// } -// -// private fun createTimeline(initialEventId: String? = null): Timeline { -// val taskExecutor = TaskExecutor(testCoroutineDispatchers) -// val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy) -// val paginationTask = FakePaginationTask @Inject constructor(tokenChunkEventPersistor) -// val getContextOfEventTask = FakeGetContextOfEventTask @Inject constructor(tokenChunkEventPersistor) -// val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID) -// val timelineEventFactory = TimelineEventFactory(roomMemberExtractor, EventRelationExtractor()) -// return DefaultTimeline( -// ROOM_ID, -// initialEventId, -// monarchy.realmConfiguration, -// taskExecutor, -// getContextOfEventTask, -// timelineEventFactory, -// paginationTask, -// null) -// } -// -// @Test -// fun backPaginate_shouldLoadMoreEvents_whenPaginateIsCalled() { -// val timeline = createTimeline() -// timeline.start() -// val paginationCount = 30 -// var initialLoad = 0 -// val latch = CountDownLatch(2) -// var timelineEvents: List = emptyList() -// timeline.listener = object : Timeline.Listener { -// override fun onTimelineUpdated(snapshot: List) { -// if (snapshot.isNotEmpty()) { -// if (initialLoad == 0) { -// initialLoad = snapshot.size -// } -// timelineEvents = snapshot -// latch.countDown() -// timeline.paginate(Timeline.Direction.BACKWARDS, paginationCount) -// } -// } -// } -// latch.await() -// timelineEvents.size shouldBeEqualTo initialLoad + paginationCount -// timeline.dispose() -// } -} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/DisplayMaths.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/DisplayMaths.kt new file mode 100644 index 0000000000..b8ee36e724 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/DisplayMaths.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 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.commonmark.ext.maths + +import org.commonmark.node.CustomBlock + +class DisplayMaths(private val delimiter: DisplayDelimiter) : CustomBlock() { + enum class DisplayDelimiter { + DOUBLE_DOLLAR, + SQUARE_BRACKET_ESCAPED + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/InlineMaths.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/InlineMaths.kt new file mode 100644 index 0000000000..962b1b8cbf --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/InlineMaths.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 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.commonmark.ext.maths + +import org.commonmark.node.CustomNode +import org.commonmark.node.Delimited + +class InlineMaths(private val delimiter: InlineDelimiter) : CustomNode(), Delimited { + enum class InlineDelimiter { + SINGLE_DOLLAR, + ROUND_BRACKET_ESCAPED + } + + override fun getOpeningDelimiter(): String { + return when (delimiter) { + InlineDelimiter.SINGLE_DOLLAR -> "$" + InlineDelimiter.ROUND_BRACKET_ESCAPED -> "\\(" + } + } + + override fun getClosingDelimiter(): String { + return when (delimiter) { + InlineDelimiter.SINGLE_DOLLAR -> "$" + InlineDelimiter.ROUND_BRACKET_ESCAPED -> "\\)" + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/MathsExtension.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/MathsExtension.kt new file mode 100644 index 0000000000..18c0fc4284 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/MathsExtension.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 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.commonmark.ext.maths + +import org.commonmark.Extension +import org.commonmark.ext.maths.internal.DollarMathsDelimiterProcessor +import org.commonmark.ext.maths.internal.MathsHtmlNodeRenderer +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer + +class MathsExtension private constructor() : Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension { + override fun extend(parserBuilder: Parser.Builder) { + parserBuilder.customDelimiterProcessor(DollarMathsDelimiterProcessor()) + } + + override fun extend(rendererBuilder: HtmlRenderer.Builder) { + rendererBuilder.nodeRendererFactory { context -> MathsHtmlNodeRenderer(context) } + } + + companion object { + fun create(): Extension { + return MathsExtension() + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/DollarMathsDelimiterProcessor.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/DollarMathsDelimiterProcessor.kt new file mode 100644 index 0000000000..cfd03fa8f1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/DollarMathsDelimiterProcessor.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 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.commonmark.ext.maths.internal + +import org.commonmark.ext.maths.DisplayMaths +import org.commonmark.ext.maths.InlineMaths +import org.commonmark.node.Text +import org.commonmark.parser.delimiter.DelimiterProcessor +import org.commonmark.parser.delimiter.DelimiterRun + +class DollarMathsDelimiterProcessor : DelimiterProcessor { + override fun getOpeningCharacter() = '$' + + override fun getClosingCharacter() = '$' + + override fun getMinLength() = 1 + + override fun getDelimiterUse(opener: DelimiterRun, closer: DelimiterRun): Int { + return if (opener.length() == 1 && closer.length() == 1) 1 // inline + else if (opener.length() == 2 && closer.length() == 2) 2 // display + else 0 + } + + override fun process(opener: Text, closer: Text, delimiterUse: Int) { + val maths = if (delimiterUse == 1) { + InlineMaths(InlineMaths.InlineDelimiter.SINGLE_DOLLAR) + } else { + DisplayMaths(DisplayMaths.DisplayDelimiter.DOUBLE_DOLLAR) + } + var tmp = opener.next + while (tmp != null && tmp !== closer) { + val next = tmp.next + maths.appendChild(tmp) + tmp = next + } + opener.insertAfter(maths) + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsHtmlNodeRenderer.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsHtmlNodeRenderer.kt new file mode 100644 index 0000000000..94652ed7ad --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsHtmlNodeRenderer.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 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.commonmark.ext.maths.internal + +import org.commonmark.ext.maths.DisplayMaths +import org.commonmark.node.Node +import org.commonmark.node.Text +import org.commonmark.renderer.html.HtmlNodeRendererContext +import org.commonmark.renderer.html.HtmlWriter +import java.util.Collections + +class MathsHtmlNodeRenderer(private val context: HtmlNodeRendererContext) : MathsNodeRenderer() { + private val html: HtmlWriter = context.writer + override fun render(node: Node) { + val display = node.javaClass == DisplayMaths::class.java + val contents = node.firstChild // should be the only child + val latex = (contents as Text).literal + val attributes = context.extendAttributes(node, if (display) "div" else "span", Collections.singletonMap("data-mx-maths", + latex)) + html.tag(if (display) "div" else "span", attributes) + html.tag("code") + context.render(contents) + html.tag("/code") + html.tag(if (display) "/div" else "/span") + } +} diff --git a/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsNodeRenderer.kt b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsNodeRenderer.kt new file mode 100644 index 0000000000..55cdc05c39 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/commonmark/ext/maths/internal/MathsNodeRenderer.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 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.commonmark.ext.maths.internal + +import org.commonmark.ext.maths.DisplayMaths +import org.commonmark.ext.maths.InlineMaths +import org.commonmark.node.Node +import org.commonmark.renderer.NodeRenderer +import java.util.HashSet + +abstract class MathsNodeRenderer : NodeRenderer { + override fun getNodeTypes(): Set> { + val types: MutableSet> = HashSet() + types.add(InlineMaths::class.java) + types.add(DisplayMaths::class.java) + return types + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt index 69b15ff7d4..91167d896f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/UserAccountDataTypes.kt @@ -27,4 +27,5 @@ object UserAccountDataTypes { const val TYPE_ALLOWED_WIDGETS = "im.vector.setting.allowed_widgets" const val TYPE_IDENTITY_SERVER = "m.identity_server" const val TYPE_ACCEPTED_TERMS = "m.accepted_terms" + const val TYPE_OVERRIDE_COLORS = "im.vector.setting.override_colors" } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/accountdata/RoomAccountDataTypes.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/accountdata/RoomAccountDataTypes.kt index 96eb86c0d6..312fb7e164 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/accountdata/RoomAccountDataTypes.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/accountdata/RoomAccountDataTypes.kt @@ -21,4 +21,5 @@ object RoomAccountDataTypes { const val EVENT_TYPE_TAG = "m.tag" const val EVENT_TYPE_FULLY_READ = "m.fully_read" const val EVENT_TYPE_SPACE_ORDER = "org.matrix.msc3230.space_order" // m.space_order + const val EVENT_TYPE_TAGGED_EVENTS = "m.tagged_events" } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt index 06c88db831..241e5f3b9b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt @@ -71,14 +71,10 @@ interface Timeline { fun paginate(direction: Direction, count: Int) /** - * Returns the number of sending events + * This is the same than the regular paginate method but waits for the results instead + * of relying on the timeline listener. */ - fun pendingEventCount(): Int - - /** - * Returns the number of failed sending events. - */ - fun failedToDeliverEventCount(): Int + suspend fun awaitPaginate(direction: Direction, count: Int): List /** * Returns the index of a built event or null. @@ -86,14 +82,14 @@ interface Timeline { fun getIndexOfEvent(eventId: String?): Int? /** - * Returns the built [TimelineEvent] at index or null + * Returns the current pagination state for the direction. */ - fun getTimelineEventAtIndex(index: Int): TimelineEvent? + fun getPaginationState(direction: Direction): PaginationState /** - * Returns the built [TimelineEvent] with eventId or null + * Returns a snapshot of the timeline in his current state. */ - fun getTimelineEventWithId(eventId: String?): TimelineEvent? + fun getSnapshot(): List interface Listener { /** @@ -101,19 +97,33 @@ interface Timeline { * The latest event is the first in the list * @param snapshot the most up to date snapshot */ - fun onTimelineUpdated(snapshot: List) + fun onTimelineUpdated(snapshot: List) = Unit /** * Called whenever an error we can't recover from occurred */ - fun onTimelineFailure(throwable: Throwable) + fun onTimelineFailure(throwable: Throwable) = Unit /** * Called when new events come through the sync */ - fun onNewTimelineEvents(eventIds: List) + fun onNewTimelineEvents(eventIds: List) = Unit + + /** + * Called when the pagination state has changed in one direction + */ + fun onStateUpdated(direction: Direction, state: PaginationState) = Unit } + /** + * Pagination state + */ + data class PaginationState( + val hasMoreToLoad: Boolean = true, + val loading: Boolean = false, + val inError: Boolean = false + ) + /** * This is used to paginate in one or another direction. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 932439c81c..45dc322420 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -47,6 +47,10 @@ data class TimelineEvent( */ val localId: Long, val eventId: String, + /** + * This display index is the position in the current chunk. + * It's not unique on the timeline as it's reset on each chunk. + */ val displayIndex: Int, val senderInfo: SenderInfo, val annotations: EventAnnotationsSummary? = null, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt index 7d9c351410..9dd369f426 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt @@ -429,7 +429,17 @@ internal class DefaultCryptoService @Inject constructor( val currentCount = syncResponse.deviceOneTimeKeysCount.signedCurve25519 ?: 0 oneTimeKeysUploader.updateOneTimeKeyCount(currentCount) } - if (isStarted()) { + // There is a limit of to_device events returned per sync. + // If we are in a case of such limited to_device sync we can't try to generate/upload + // new otk now, because there might be some pending olm pre-key to_device messages that would fail if we rotate + // the old otk too early. In this case we want to wait for the pending to_device before doing anything + // As per spec: + // If there is a large queue of send-to-device messages, the server should limit the number sent in each /sync response. + // 100 messages is recommended as a reasonable limit. + // The limit is not part of the spec, so it's probably safer to handle that when there are no more to_device ( so we are sure + // that there are no pending to_device + val toDevices = syncResponse.toDevice?.events.orEmpty() + if (isStarted() && toDevices.isEmpty()) { // Make sure we process to-device messages before generating new one-time-keys #2782 deviceListManager.refreshOutdatedDeviceLists() // The presence of device_unused_fallback_key_types indicates that the server supports fallback keys. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt index 8bbc71543c..ceceedc802 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt @@ -133,6 +133,11 @@ internal class MXMegolmDecryption(private val userId: String, if (requestKeysOnFail) { requestKeysForEvent(event, false) } + + throw MXCryptoError.Base( + MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX, + "UNKNOWN_MESSAGE_INDEX", + null) } val reason = String.format(MXCryptoError.OLM_REASON, throwable.olmException.message) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/DatabaseCleaner.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/DatabaseCleaner.kt deleted file mode 100644 index 7341d4656a..0000000000 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/DatabaseCleaner.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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.internal.database - -import io.realm.Realm -import io.realm.RealmConfiguration -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.SessionLifecycleObserver -import org.matrix.android.sdk.internal.database.helper.nextDisplayIndex -import org.matrix.android.sdk.internal.database.model.ChunkEntity -import org.matrix.android.sdk.internal.database.model.ChunkEntityFields -import org.matrix.android.sdk.internal.database.model.EventEntity -import org.matrix.android.sdk.internal.database.model.RoomEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields -import org.matrix.android.sdk.internal.database.model.deleteOnCascade -import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection -import org.matrix.android.sdk.internal.task.TaskExecutor -import timber.log.Timber -import javax.inject.Inject - -private const val MAX_NUMBER_OF_EVENTS_IN_DB = 35_000L -private const val MIN_NUMBER_OF_EVENTS_BY_CHUNK = 300 - -/** - * This class makes sure to stay under a maximum number of events as it makes Realm to be unusable when listening to events - * when the database is getting too big. This will try incrementally to remove the biggest chunks until we get below the threshold. - * We make sure to still have a minimum number of events so it's not becoming unusable. - * So this won't work for users with a big number of very active rooms. - */ -internal class DatabaseCleaner @Inject constructor(@SessionDatabase private val realmConfiguration: RealmConfiguration, - private val taskExecutor: TaskExecutor) : SessionLifecycleObserver { - - override fun onSessionStarted(session: Session) { - taskExecutor.executorScope.launch(Dispatchers.Default) { - awaitTransaction(realmConfiguration) { realm -> - val allRooms = realm.where(RoomEntity::class.java).findAll() - Timber.v("There are ${allRooms.size} rooms in this session") - cleanUp(realm, MAX_NUMBER_OF_EVENTS_IN_DB / 2L) - } - } - } - - private fun cleanUp(realm: Realm, threshold: Long) { - val numberOfEvents = realm.where(EventEntity::class.java).findAll().size - val numberOfTimelineEvents = realm.where(TimelineEventEntity::class.java).findAll().size - Timber.v("Number of events in db: $numberOfEvents | Number of timeline events in db: $numberOfTimelineEvents") - if (threshold <= MIN_NUMBER_OF_EVENTS_BY_CHUNK || numberOfTimelineEvents < MAX_NUMBER_OF_EVENTS_IN_DB) { - Timber.v("Db is low enough") - } else { - val thresholdChunks = realm.where(ChunkEntity::class.java) - .greaterThan(ChunkEntityFields.NUMBER_OF_TIMELINE_EVENTS, threshold) - .findAll() - - Timber.v("There are ${thresholdChunks.size} chunks to clean with more than $threshold events") - for (chunk in thresholdChunks) { - val maxDisplayIndex = chunk.nextDisplayIndex(PaginationDirection.FORWARDS) - val thresholdDisplayIndex = maxDisplayIndex - threshold - val eventsToRemove = chunk.timelineEvents.where().lessThan(TimelineEventEntityFields.DISPLAY_INDEX, thresholdDisplayIndex).findAll() - Timber.v("There are ${eventsToRemove.size} events to clean in chunk: ${chunk.identifier()} from room ${chunk.room?.first()?.roomId}") - chunk.numberOfTimelineEvents = chunk.numberOfTimelineEvents - eventsToRemove.size - eventsToRemove.forEach { - val canDeleteRoot = it.root?.stateKey == null - it.deleteOnCascade(canDeleteRoot) - } - // We reset the prevToken so we will need to fetch again. - chunk.prevToken = null - } - cleanUp(realm, (threshold / 1.5).toLong()) - } - } -} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 2256d93100..508af250c2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -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.internal.database.model.ChunkEntityFields import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntityFields import org.matrix.android.sdk.internal.database.model.EditionOfEventFields @@ -54,7 +55,7 @@ internal class RealmSessionStoreMigration @Inject constructor( ) : RealmMigration { companion object { - const val SESSION_STORE_SCHEMA_VERSION = 19L + const val SESSION_STORE_SCHEMA_VERSION = 20L } /** @@ -86,6 +87,7 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion <= 16) migrateTo17(realm) if (oldVersion <= 17) migrateTo18(realm) if (oldVersion <= 18) migrateTo19(realm) + if (oldVersion <= 19) migrateTo20(realm) } private fun migrateTo1(realm: DynamicRealm) { @@ -390,4 +392,26 @@ internal class RealmSessionStoreMigration @Inject constructor( } } } + + private fun migrateTo20(realm: DynamicRealm) { + Timber.d("Step 19 -> 20") + realm.schema.get("ChunkEntity")?.apply { + if (hasField("numberOfTimelineEvents")) { + removeField("numberOfTimelineEvents") + } + var cleanOldChunks = false + if (!hasField(ChunkEntityFields.NEXT_CHUNK.`$`)) { + cleanOldChunks = true + addRealmObjectField(ChunkEntityFields.NEXT_CHUNK.`$`, this) + } + if (!hasField(ChunkEntityFields.PREV_CHUNK.`$`)) { + cleanOldChunks = true + addRealmObjectField(ChunkEntityFields.PREV_CHUNK.`$`, this) + } + if (cleanOldChunks) { + val chunkEntities = realm.where("ChunkEntity").equalTo(ChunkEntityFields.IS_LAST_FORWARD, false).findAll() + chunkEntities.deleteAllFromRealm() + } + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt index f74e4b0f4c..c21bf74d93 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt @@ -110,7 +110,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, true } } - numberOfTimelineEvents++ + // numberOfTimelineEvents++ timelineEvents.add(timelineEventEntity) } @@ -191,3 +191,29 @@ internal fun ChunkEntity.nextDisplayIndex(direction: PaginationDirection): Int { } } } + +internal fun ChunkEntity.doesNextChunksVerifyCondition(linkCondition: (ChunkEntity) -> Boolean): Boolean { + var nextChunkToCheck = this.nextChunk + while (nextChunkToCheck != null) { + if (linkCondition(nextChunkToCheck)) { + return true + } + nextChunkToCheck = nextChunkToCheck.nextChunk + } + return false +} + +internal fun ChunkEntity.isMoreRecentThan(chunkToCheck: ChunkEntity): Boolean { + if (this.isLastForward) return true + if (chunkToCheck.isLastForward) return false + // Check if the chunk to check is linked to this one + if (chunkToCheck.doesNextChunksVerifyCondition { it == this }) { + return true + } + // Otherwise check if this chunk is linked to last forward + if (this.doesNextChunksVerifyCondition { it.isLastForward }) { + return true + } + // We don't know, so we assume it's false + return false +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/TimelineEventEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/TimelineEventEntityHelper.kt index 3993e8e799..1d2cbcad51 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/TimelineEventEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/TimelineEventEntityHelper.kt @@ -28,3 +28,13 @@ internal fun TimelineEventEntity.Companion.nextId(realm: Realm): Long { currentIdNum.toLong() + 1 } } + +internal fun TimelineEventEntity.isMoreRecentThan(eventToCheck: TimelineEventEntity): Boolean { + val currentChunk = this.chunk?.first() ?: return false + val chunkToCheck = eventToCheck.chunk?.firstOrNull() ?: return false + return if (currentChunk == chunkToCheck) { + this.displayIndex >= eventToCheck.displayIndex + } else { + currentChunk.isMoreRecentThan(chunkToCheck) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt index 68533a3c19..ecb602019a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt @@ -27,9 +27,10 @@ import org.matrix.android.sdk.internal.extensions.clearWith internal open class ChunkEntity(@Index var prevToken: String? = null, // Because of gaps we can have several chunks with nextToken == null @Index var nextToken: String? = null, + var prevChunk: ChunkEntity? = null, + var nextChunk: ChunkEntity? = null, var stateEvents: RealmList = RealmList(), var timelineEvents: RealmList = RealmList(), - var numberOfTimelineEvents: Long = 0, // Only one chunk will have isLastForward == true @Index var isLastForward: Boolean = false, @Index var isLastBackward: Boolean = false diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt index 836fc4efaf..ce2d1efc1d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt @@ -40,8 +40,6 @@ internal open class EventEntity(@Index var eventId: String = "", var unsignedData: String? = null, var redacts: String? = null, var decryptionResultJson: String? = null, - var decryptionErrorCode: String? = null, - var decryptionErrorReason: String? = null, var ageLocalTs: Long? = null ) : RealmObject() { @@ -55,6 +53,16 @@ internal open class EventEntity(@Index var eventId: String = "", sendStateStr = value.name } + var decryptionErrorCode: String? = null + set(value) { + if (value != field) field = value + } + + var decryptionErrorReason: String? = null + set(value) { + if (value != field) field = value + } + companion object fun setDecryptionResult(result: MXEventDecryptionResult, clearEvent: JsonDict? = null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt index 30bbde70c2..185f0e2dcc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt @@ -46,7 +46,5 @@ internal fun TimelineEventEntity.deleteOnCascade(canDeleteRoot: Boolean) { if (canDeleteRoot) { root?.deleteFromRealm() } - annotations?.deleteOnCascade() - readReceipts?.deleteOnCascade() deleteFromRealm() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt index 60096777d9..c9c96b9cc1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.database.query import io.realm.Realm import io.realm.RealmConfiguration import org.matrix.android.sdk.api.session.events.model.LocalEcho +import org.matrix.android.sdk.internal.database.helper.isMoreRecentThan import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ReadMarkerEntity import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity @@ -33,28 +34,26 @@ internal fun isEventRead(realmConfiguration: RealmConfiguration, if (LocalEcho.isLocalEchoId(eventId)) { return true } + // If we don't know if the event has been read, we assume it's not var isEventRead = false Realm.getInstance(realmConfiguration).use { realm -> - val liveChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) ?: return@use - val eventToCheck = liveChunk.timelineEvents.find(eventId) + val latestEvent = TimelineEventEntity.latestEvent(realm, roomId, true) + // If latest event is from you we are sure the event is read + if (latestEvent?.root?.sender == userId) { + return true + } + val eventToCheck = TimelineEventEntity.where(realm, roomId, eventId).findFirst() isEventRead = when { - eventToCheck == null -> hasReadMissingEvent( - realm = realm, - latestChunkEntity = liveChunk, - roomId = roomId, - userId = userId, - eventId = eventId - ) + eventToCheck == null -> false eventToCheck.root?.sender == userId -> true else -> { val readReceipt = ReadReceiptEntity.where(realm, roomId, userId).findFirst() ?: return@use - val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.displayIndex ?: Int.MIN_VALUE - eventToCheck.displayIndex <= readReceiptIndex + val readReceiptEvent = TimelineEventEntity.where(realm, roomId, readReceipt.eventId).findFirst() ?: return@use + readReceiptEvent.isMoreRecentThan(eventToCheck) } } } - return isEventRead } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt index ebc2176a13..e2cfea479d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt @@ -47,7 +47,6 @@ import org.matrix.android.sdk.internal.crypto.secrets.DefaultSharedSecretStorage import org.matrix.android.sdk.internal.crypto.tasks.DefaultRedactEventTask import org.matrix.android.sdk.internal.crypto.tasks.RedactEventTask import org.matrix.android.sdk.internal.crypto.verification.VerificationMessageProcessor -import org.matrix.android.sdk.internal.database.DatabaseCleaner import org.matrix.android.sdk.internal.database.EventInsertLiveObserver import org.matrix.android.sdk.internal.database.RealmSessionProvider import org.matrix.android.sdk.internal.database.SessionRealmConfigurationFactory @@ -339,10 +338,6 @@ internal abstract class SessionModule { @IntoSet abstract fun bindIdentityService(service: DefaultIdentityService): SessionLifecycleObserver - @Binds - @IntoSet - abstract fun bindDatabaseCleaner(cleaner: DatabaseCleaner): SessionLifecycleObserver - @Binds @IntoSet abstract fun bindRealmSessionProvider(provider: RealmSessionProvider): SessionLifecycleObserver diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt index 1b0ccbb489..b988f2253c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt @@ -109,18 +109,23 @@ internal class FileUploader @Inject constructor( filename: String?, mimeType: String?, progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { - val inputStream = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri) - } ?: throw FileNotFoundException() - val workingFile = temporaryFileCreator.create() - workingFile.outputStream().use { - inputStream.copyTo(it) - } + val workingFile = context.copyUriToTempFile(uri) return uploadFile(workingFile, filename, mimeType, progressListener).also { tryOrNull { workingFile.delete() } } } + private suspend fun Context.copyUriToTempFile(uri: Uri): File { + return withContext(Dispatchers.IO) { + val inputStream = contentResolver.openInputStream(uri) ?: throw FileNotFoundException() + val workingFile = temporaryFileCreator.create() + workingFile.outputStream().use { + inputStream.copyTo(it) + } + workingFile + } + } + private suspend fun upload(uploadBody: RequestBody, filename: String?, progressListener: ProgressRequestBody.Listener?): ContentUploadResponse { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/DefaultProfileService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/DefaultProfileService.kt index a19832c523..caf4158657 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/DefaultProfileService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/DefaultProfileService.kt @@ -68,7 +68,7 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto } override suspend fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String) { - withContext(coroutineDispatchers.main) { + withContext(coroutineDispatchers.io) { val response = fileUploader.uploadFromUri(newAvatarUri, fileName, MimeTypes.Jpeg) setAvatarUrlTask.execute(SetAvatarUrlTask.Params(userId = userId, newAvatarUrl = response.contentUri)) userStore.updateAvatar(userId, response.contentUri) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index 0af05d8df2..64f6bc0b30 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -19,6 +19,8 @@ package org.matrix.android.sdk.internal.session.room import dagger.Binds import dagger.Module import dagger.Provides +import org.commonmark.Extension +import org.commonmark.ext.maths.MathsExtension import org.commonmark.node.BlockQuote import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer @@ -120,6 +122,8 @@ internal abstract class RoomModule { @Module companion object { + private val extensions: List = listOf(MathsExtension.create()) + @Provides @JvmStatic @SessionScope @@ -138,7 +142,7 @@ internal abstract class RoomModule { @AdvancedCommonmarkParser @JvmStatic fun providesAdvancedParser(): Parser { - return Parser.builder().build() + return Parser.builder().extensions(extensions).build() } @Provides @@ -157,6 +161,7 @@ internal abstract class RoomModule { fun providesHtmlRenderer(): HtmlRenderer { return HtmlRenderer .builder() + .extensions(extensions) .softbreak("
") .build() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt index 28f55a01ee..b30c66c82e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt @@ -34,12 +34,10 @@ import org.matrix.android.sdk.internal.database.query.isEventRead import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.UserId -import org.matrix.android.sdk.internal.task.TaskExecutor internal class DefaultReadService @AssistedInject constructor( @Assisted private val roomId: String, @SessionDatabase private val monarchy: Monarchy, - private val taskExecutor: TaskExecutor, private val setReadMarkersTask: SetReadMarkersTask, private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper, @UserId private val userId: String diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/CancelSendTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/CancelSendTracker.kt index 5f6ebc68c2..fe180536c8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/CancelSendTracker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/CancelSendTracker.kt @@ -44,10 +44,10 @@ internal class CancelSendTracker @Inject constructor() { } fun isCancelRequestedFor(eventId: String?, roomId: String?): Boolean { - val index = synchronized(cancellingRequests) { - cancellingRequests.indexOfFirst { it.localId == eventId && it.roomId == roomId } + val found = synchronized(cancellingRequests) { + cancellingRequests.any { it.localId == eventId && it.roomId == roomId } } - return index != -1 + return found } fun markCancelled(eventId: String, roomId: String) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt index e442d051f6..ef7945cf8c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MarkdownParser.kt @@ -35,7 +35,7 @@ internal class MarkdownParser @Inject constructor( private val textPillsUtils: TextPillsUtils ) { - private val mdSpecialChars = "[`_\\-*>.\\[\\]#~]".toRegex() + private val mdSpecialChars = "[`_\\-*>.\\[\\]#~$]".toRegex() /** * Parses some input text and produces html. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index 3556cabb33..70562de998 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.summary import io.realm.Realm import io.realm.kotlin.createObject +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel @@ -136,7 +137,7 @@ internal class RoomSummaryUpdater @Inject constructor( roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0 || // avoid this call if we are sure there are unread events - !isEventRead(realm.configuration, userId, roomId, latestPreviewableEvent?.eventId) + latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId) } ?: false roomSummaryEntity.setDisplayName(roomDisplayNameResolver.resolve(realm, roomId)) roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(realm, roomId) @@ -236,7 +237,7 @@ internal class RoomSummaryUpdater @Inject constructor( .findFirst() ?.let { childSum -> lookupMap.entries.firstOrNull { it.key.roomId == lookedUp.roomId }?.let { entry -> - if (entry.value.indexOfFirst { it.roomId == childSum.roomId } == -1) { + if (entry.value.none { it.roomId == childSum.roomId }) { // add looked up as a parent entry.value.add(childSum) } @@ -299,7 +300,7 @@ internal class RoomSummaryUpdater @Inject constructor( .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) .findFirst() ?.let { parentSum -> - if (lookupMap[parentSum]?.indexOfFirst { it.roomId == lookedUp.roomId } == -1) { + if (lookupMap[parentSum]?.none { it.roomId == lookedUp.roomId }.orFalse()) { // add lookedup as a parent lookupMap[parentSum]?.add(lookedUp) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/taggedevents/TaggedEventsContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/taggedevents/TaggedEventsContent.kt new file mode 100644 index 0000000000..1b19d27e1d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/taggedevents/TaggedEventsContent.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.taggedevents + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Keys are event IDs, values are event information. + */ +typealias TaggedEvent = Map + +/** + * Keys are tagged event names (eg. m.favourite), values are the related events. + */ +typealias TaggedEvents = Map + +/** + * Class used to parse the content of a m.tagged_events type event. + * This kind of event defines the tagged events in a room. + * + * The content of this event is a tags key whose value is an object mapping the name of each tag + * to another object. The JSON object associated with each tag is an object where the keys are the + * event IDs and values give information about the events. + * + * Ref: https://github.com/matrix-org/matrix-doc/pull/2437 + */ +@JsonClass(generateAdapter = true) +data class TaggedEventsContent( + @Json(name = "tags") + var tags: TaggedEvents = emptyMap() +) { + val favouriteEvents + get() = tags[TAG_FAVOURITE].orEmpty() + + val hiddenEvents + get() = tags[TAG_HIDDEN].orEmpty() + + fun tagEvent(eventId: String, info: TaggedEventInfo, tag: String) { + val taggedEvents = tags[tag].orEmpty().plus(eventId to info) + tags = tags.plus(tag to taggedEvents) + } + + fun untagEvent(eventId: String, tag: String) { + val taggedEvents = tags[tag]?.minus(eventId).orEmpty() + tags = tags.plus(tag to taggedEvents) + } + + companion object { + const val TAG_FAVOURITE = "m.favourite" + const val TAG_HIDDEN = "m.hidden" + } +} + +@JsonClass(generateAdapter = true) +data class TaggedEventInfo( + @Json(name = "keywords") + val keywords: List? = null, + + @Json(name = "origin_server_ts") + val originServerTs: Long? = null, + + @Json(name = "tagged_at") + val taggedAt: Long? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index 2744b5129e..71823cd458 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright (c) 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. @@ -16,172 +16,328 @@ package org.matrix.android.sdk.internal.session.room.timeline -import io.realm.OrderedCollectionChangeSet -import io.realm.OrderedRealmCollectionChangeListener import io.realm.Realm import io.realm.RealmConfiguration -import io.realm.RealmQuery -import io.realm.RealmResults -import io.realm.Sort -import kotlinx.coroutines.runBlocking -import org.matrix.android.sdk.api.MatrixCallback -import org.matrix.android.sdk.api.extensions.orFalse +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.android.asCoroutineDispatcher +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.internal.closeQuietly +import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings -import org.matrix.android.sdk.api.util.CancelableBag -import org.matrix.android.sdk.internal.database.RealmSessionProvider -import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper -import org.matrix.android.sdk.internal.database.model.ChunkEntity -import org.matrix.android.sdk.internal.database.model.RoomEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields -import org.matrix.android.sdk.internal.database.query.findAllInRoomWithSendStates -import org.matrix.android.sdk.internal.database.query.where -import org.matrix.android.sdk.internal.database.query.whereRoomId import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler -import org.matrix.android.sdk.internal.task.TaskExecutor -import org.matrix.android.sdk.internal.task.configureWith -import org.matrix.android.sdk.internal.util.Debouncer +import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer import org.matrix.android.sdk.internal.util.createBackgroundHandler -import org.matrix.android.sdk.internal.util.createUIHandler import timber.log.Timber -import java.util.Collections import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference -import kotlin.math.max -private const val MIN_FETCHING_COUNT = 30 - -internal class DefaultTimeline( - private val roomId: String, - private var initialEventId: String? = null, - private val realmConfiguration: RealmConfiguration, - private val taskExecutor: TaskExecutor, - private val contextOfEventTask: GetContextOfEventTask, - private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, - private val paginationTask: PaginationTask, - private val timelineEventMapper: TimelineEventMapper, - private val settings: TimelineSettings, - private val timelineInput: TimelineInput, - private val eventDecryptor: TimelineEventDecryptor, - private val realmSessionProvider: RealmSessionProvider, - private val loadRoomMembersTask: LoadRoomMembersTask, - private val threadsAwarenessHandler: ThreadsAwarenessHandler, - private val readReceiptHandler: ReadReceiptHandler -) : Timeline, - TimelineInput.Listener, - UIEchoManager.Listener { +internal class DefaultTimeline(private val roomId: String, + private val initialEventId: String?, + private val realmConfiguration: RealmConfiguration, + private val loadRoomMembersTask: LoadRoomMembersTask, + private val readReceiptHandler: ReadReceiptHandler, + private val settings: TimelineSettings, + private val coroutineDispatchers: MatrixCoroutineDispatchers, + paginationTask: PaginationTask, + getEventTask: GetContextOfEventTask, + fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, + timelineEventMapper: TimelineEventMapper, + timelineInput: TimelineInput, + threadsAwarenessHandler: ThreadsAwarenessHandler, + eventDecryptor: TimelineEventDecryptor) : Timeline { companion object { - val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD") + val BACKGROUND_HANDLER = createBackgroundHandler("DefaultTimeline_Thread") } - private val listeners = CopyOnWriteArrayList() - private val isStarted = AtomicBoolean(false) - private val isReady = AtomicBoolean(false) - private val mainHandler = createUIHandler() - private val backgroundRealm = AtomicReference() - private val cancelableBag = CancelableBag() - private val debouncer = Debouncer(mainHandler) - - private lateinit var timelineEvents: RealmResults - private lateinit var sendingEvents: RealmResults - - private var prevDisplayIndex: Int? = null - private var nextDisplayIndex: Int? = null - - private val uiEchoManager = UIEchoManager(settings, this) - - private val builtEvents = Collections.synchronizedList(ArrayList()) - private val builtEventsIdMap = Collections.synchronizedMap(HashMap()) - private val backwardsState = AtomicReference(TimelineState()) - private val forwardsState = AtomicReference(TimelineState()) - override val timelineID = UUID.randomUUID().toString() - override val isLive - get() = !hasMoreToLoad(Timeline.Direction.FORWARDS) + private val listeners = CopyOnWriteArrayList() + private val isStarted = AtomicBoolean(false) + private val forwardState = AtomicReference(Timeline.PaginationState()) + private val backwardState = AtomicReference(Timeline.PaginationState()) - private val eventsChangeListener = OrderedRealmCollectionChangeListener> { results, changeSet -> - if (!results.isLoaded || !results.isValid) { - return@OrderedRealmCollectionChangeListener + private val backgroundRealm = AtomicReference() + private val timelineDispatcher = BACKGROUND_HANDLER.asCoroutineDispatcher() + private val timelineScope = CoroutineScope(SupervisorJob() + timelineDispatcher) + private val sequencer = SemaphoreCoroutineSequencer() + private val postSnapshotSignalFlow = MutableSharedFlow(0) + + private val strategyDependencies = LoadTimelineStrategy.Dependencies( + timelineSettings = settings, + realm = backgroundRealm, + eventDecryptor = eventDecryptor, + paginationTask = paginationTask, + fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, + getContextOfEventTask = getEventTask, + timelineInput = timelineInput, + timelineEventMapper = timelineEventMapper, + threadsAwarenessHandler = threadsAwarenessHandler, + onEventsUpdated = this::sendSignalToPostSnapshot, + onLimitedTimeline = this::onLimitedTimeline, + onNewTimelineEvents = this::onNewTimelineEvents + ) + + private var strategy: LoadTimelineStrategy = buildStrategy(LoadTimelineStrategy.Mode.Live) + + override val isLive: Boolean + get() = !getPaginationState(Timeline.Direction.FORWARDS).hasMoreToLoad + + override fun addListener(listener: Timeline.Listener): Boolean { + listeners.add(listener) + timelineScope.launch { + val snapshot = strategy.buildSnapshot() + withContext(coroutineDispatchers.main) { + tryOrNull { listener.onTimelineUpdated(snapshot) } + } } - Timber.v("## SendEvent: [${System.currentTimeMillis()}] DB update for room $roomId") - handleUpdates(results, changeSet) + return true } - // Public methods ****************************************************************************** + override fun removeListener(listener: Timeline.Listener): Boolean { + return listeners.remove(listener) + } + + override fun removeAllListeners() { + listeners.clear() + } + + override fun start() { + timelineScope.launch { + loadRoomMembersIfNeeded() + } + timelineScope.launch { + sequencer.post { + if (isStarted.compareAndSet(false, true)) { + val realm = Realm.getInstance(realmConfiguration) + ensureReadReceiptAreLoaded(realm) + backgroundRealm.set(realm) + listenToPostSnapshotSignals() + openAround(initialEventId) + postSnapshot() + } + } + } + } + + override fun dispose() { + timelineScope.coroutineContext.cancelChildren() + timelineScope.launch { + sequencer.post { + if (isStarted.compareAndSet(true, false)) { + strategy.onStop() + backgroundRealm.get().closeQuietly() + } + } + } + } + + override fun restartWithEventId(eventId: String?) { + timelineScope.launch { + openAround(eventId) + postSnapshot() + } + } + + override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { + return getPaginationState(direction).hasMoreToLoad + } override fun paginate(direction: Timeline.Direction, count: Int) { - BACKGROUND_HANDLER.post { - if (!canPaginate(direction)) { - return@post - } - Timber.v("Paginate $direction of $count items") - val startDisplayIndex = if (direction == Timeline.Direction.BACKWARDS) prevDisplayIndex else nextDisplayIndex - val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, count) - if (shouldPostSnapshot) { + timelineScope.launch { + val postSnapshot = loadMore(count, direction, fetchOnServerIfNeeded = true) + if (postSnapshot) { postSnapshot() } } } - override fun pendingEventCount(): Int { - return realmSessionProvider.withRealm { - RoomEntity.where(it, roomId).findFirst()?.sendingTimelineEvents?.count() ?: 0 + override suspend fun awaitPaginate(direction: Timeline.Direction, count: Int): List { + withContext(timelineDispatcher) { + loadMore(count, direction, fetchOnServerIfNeeded = true) + } + return getSnapshot() + } + + override fun getSnapshot(): List { + return strategy.buildSnapshot() + } + + override fun getIndexOfEvent(eventId: String?): Int? { + if (eventId == null) return null + return strategy.getBuiltEventIndex(eventId) + } + + override fun getPaginationState(direction: Timeline.Direction): Timeline.PaginationState { + return if (direction == Timeline.Direction.BACKWARDS) { + backwardState + } else { + forwardState + }.get() + } + + private suspend fun loadMore(count: Int, direction: Timeline.Direction, fetchOnServerIfNeeded: Boolean): Boolean { + val baseLogMessage = "loadMore(count: $count, direction: $direction, roomId: $roomId, fetchOnServer: $fetchOnServerIfNeeded)" + Timber.v("$baseLogMessage started") + if (!isStarted.get()) { + throw IllegalStateException("You should call start before using timeline") + } + val currentState = getPaginationState(direction) + if (!currentState.hasMoreToLoad) { + Timber.v("$baseLogMessage : nothing more to load") + return false + } + if (currentState.loading) { + Timber.v("$baseLogMessage : already loading") + return false + } + updateState(direction) { + it.copy(loading = true) + } + val loadMoreResult = strategy.loadMore(count, direction, fetchOnServerIfNeeded) + Timber.v("$baseLogMessage: result $loadMoreResult") + val hasMoreToLoad = loadMoreResult != LoadMoreResult.REACHED_END + updateState(direction) { + it.copy(loading = false, hasMoreToLoad = hasMoreToLoad) + } + return true + } + + private suspend fun openAround(eventId: String?) = withContext(timelineDispatcher) { + val baseLogMessage = "openAround(eventId: $eventId)" + Timber.v("$baseLogMessage started") + if (!isStarted.get()) { + throw IllegalStateException("You should call start before using timeline") + } + strategy.onStop() + strategy = if (eventId == null) { + buildStrategy(LoadTimelineStrategy.Mode.Live) + } else { + buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId)) + } + initPaginationStates(eventId) + strategy.onStart() + loadMore( + count = strategyDependencies.timelineSettings.initialSize, + direction = Timeline.Direction.BACKWARDS, + fetchOnServerIfNeeded = false + ) + Timber.v("$baseLogMessage finished") + } + + private fun initPaginationStates(eventId: String?) { + updateState(Timeline.Direction.FORWARDS) { + it.copy(loading = false, hasMoreToLoad = eventId != null) + } + updateState(Timeline.Direction.BACKWARDS) { + it.copy(loading = false, hasMoreToLoad = true) } } - override fun failedToDeliverEventCount(): Int { - return realmSessionProvider.withRealm { - TimelineEventEntity.findAllInRoomWithSendStates(it, roomId, SendState.HAS_FAILED_STATES).count() + private fun sendSignalToPostSnapshot(withThrottling: Boolean) { + timelineScope.launch { + if (withThrottling) { + postSnapshotSignalFlow.emit(Unit) + } else { + postSnapshot() + } } } - override fun start() { - if (isStarted.compareAndSet(false, true)) { - Timber.v("Start timeline for roomId: $roomId and eventId: $initialEventId") - timelineInput.listeners.add(this) - BACKGROUND_HANDLER.post { - eventDecryptor.start() - val realm = Realm.getInstance(realmConfiguration) - backgroundRealm.set(realm) - - val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() - ?: throw IllegalStateException("Can't open a timeline without a room") - - // We don't want to filter here because some sending events that are not displayed - // are still used for ui echo (relation like reaction) - sendingEvents = roomEntity.sendingTimelineEvents.where()/*.filterEventsWithSettings()*/.findAll() - sendingEvents.addChangeListener { events -> - uiEchoManager.onSentEventsInDatabase(events.map { it.eventId }) + @Suppress("EXPERIMENTAL_API_USAGE") + private fun listenToPostSnapshotSignals() { + postSnapshotSignalFlow + .sample(150) + .onEach { postSnapshot() } + .launchIn(timelineScope) + } - timelineEvents = buildEventQuery(realm).sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll() - timelineEvents.addChangeListener(eventsChangeListener) - handleInitialLoad() - loadRoomMembersTask - .configureWith(LoadRoomMembersTask.Params(roomId)) - .executeBy(taskExecutor) + private fun onLimitedTimeline() { + timelineScope.launch { + initPaginationStates(null) + loadMore(settings.initialSize, Timeline.Direction.BACKWARDS, false) + postSnapshot() + } + } - // Ensure ReadReceipt from init sync are loaded - ensureReadReceiptAreLoaded(realm) - - isReady.set(true) + private suspend fun postSnapshot() { + val snapshot = strategy.buildSnapshot() + Timber.v("Post snapshot of ${snapshot.size} events") + withContext(coroutineDispatchers.main) { + listeners.forEach { + tryOrNull { it.onTimelineUpdated(snapshot) } } } } + private fun onNewTimelineEvents(eventIds: List) { + timelineScope.launch(coroutineDispatchers.main) { + listeners.forEach { + tryOrNull { it.onNewTimelineEvents(eventIds) } + } + } + } + + private fun updateState(direction: Timeline.Direction, update: (Timeline.PaginationState) -> Timeline.PaginationState) { + val stateReference = when (direction) { + Timeline.Direction.FORWARDS -> forwardState + Timeline.Direction.BACKWARDS -> backwardState + } + val currentValue = stateReference.get() + val newValue = update(currentValue) + stateReference.set(newValue) + if (newValue != currentValue) { + postPaginationState(direction, newValue) + } + } + + private fun postPaginationState(direction: Timeline.Direction, state: Timeline.PaginationState) { + timelineScope.launch(coroutineDispatchers.main) { + Timber.v("Post $direction pagination state: $state ") + listeners.forEach { + tryOrNull { it.onStateUpdated(direction, state) } + } + } + } + + private fun buildStrategy(mode: LoadTimelineStrategy.Mode): LoadTimelineStrategy { + return LoadTimelineStrategy( + roomId = roomId, + timelineId = timelineID, + mode = mode, + dependencies = strategyDependencies + ) + } + + private suspend fun loadRoomMembersIfNeeded() { + val loadRoomMembersParam = LoadRoomMembersTask.Params(roomId) + try { + loadRoomMembersTask.execute(loadRoomMembersParam) + } catch (failure: Throwable) { + Timber.v("Failed to load room members. Retry in 10s.") + delay(10_000L) + loadRoomMembersIfNeeded() + } + } + private fun ensureReadReceiptAreLoaded(realm: Realm) { readReceiptHandler.getContentFromInitSync(roomId) ?.also { @@ -194,560 +350,4 @@ internal class DefaultTimeline( } } } - - override fun dispose() { - if (isStarted.compareAndSet(true, false)) { - isReady.set(false) - timelineInput.listeners.remove(this) - Timber.v("Dispose timeline for roomId: $roomId and eventId: $initialEventId") - cancelableBag.cancel() - BACKGROUND_HANDLER.removeCallbacksAndMessages(null) - BACKGROUND_HANDLER.post { - if (this::sendingEvents.isInitialized) { - sendingEvents.removeAllChangeListeners() - } - if (this::timelineEvents.isInitialized) { - timelineEvents.removeAllChangeListeners() - } - clearAllValues() - backgroundRealm.getAndSet(null).also { - it?.close() - } - eventDecryptor.destroy() - } - } - } - - override fun restartWithEventId(eventId: String?) { - dispose() - initialEventId = eventId - start() - postSnapshot() - } - - override fun getTimelineEventAtIndex(index: Int): TimelineEvent? { - return builtEvents.getOrNull(index) - } - - override fun getIndexOfEvent(eventId: String?): Int? { - return builtEventsIdMap[eventId] - } - - override fun getTimelineEventWithId(eventId: String?): TimelineEvent? { - return builtEventsIdMap[eventId]?.let { - getTimelineEventAtIndex(it) - } - } - - override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { - return hasMoreInCache(direction) || !hasReachedEnd(direction) - } - - override fun addListener(listener: Timeline.Listener): Boolean { - if (listeners.contains(listener)) { - return false - } - return listeners.add(listener).also { - postSnapshot() - } - } - - override fun removeListener(listener: Timeline.Listener): Boolean { - return listeners.remove(listener) - } - - override fun removeAllListeners() { - listeners.clear() - } - - override fun onNewTimelineEvents(roomId: String, eventIds: List) { - if (isLive && this.roomId == roomId) { - listeners.forEach { - it.onNewTimelineEvents(eventIds) - } - } - } - - override fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent) { - if (roomId != this.roomId || !isLive) return - uiEchoManager.onLocalEchoCreated(timelineEvent) - listeners.forEach { - tryOrNull { - it.onNewTimelineEvents(listOf(timelineEvent.eventId)) - } - } - postSnapshot() - } - - override fun onLocalEchoUpdated(roomId: String, eventId: String, sendState: SendState) { - if (roomId != this.roomId || !isLive) return - if (uiEchoManager.onSendStateUpdated(eventId, sendState)) { - postSnapshot() - } - } - - override fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent?): Boolean { - return tryOrNull { - builtEventsIdMap[eventId]?.let { builtIndex -> - // Update the relation of existing event - builtEvents[builtIndex]?.let { te -> - val rebuiltEvent = builder(te) - // If rebuilt event is filtered its returned as null and should be removed. - if (rebuiltEvent == null) { - builtEventsIdMap.remove(eventId) - builtEventsIdMap.entries.filter { it.value > builtIndex }.forEach { it.setValue(it.value - 1) } - builtEvents.removeAt(builtIndex) - } else { - builtEvents[builtIndex] = rebuiltEvent - } - true - } - } - } ?: false - } - -// Private methods ***************************************************************************** - - private fun hasMoreInCache(direction: Timeline.Direction) = getState(direction).hasMoreInCache - - private fun hasReachedEnd(direction: Timeline.Direction) = getState(direction).hasReachedEnd - - private fun updateLoadingStates(results: RealmResults) { - val lastCacheEvent = results.lastOrNull() - val firstCacheEvent = results.firstOrNull() - val chunkEntity = getLiveChunk() - - updateState(Timeline.Direction.FORWARDS) { - it.copy( - hasMoreInCache = !builtEventsIdMap.containsKey(firstCacheEvent?.eventId), - hasReachedEnd = chunkEntity?.isLastForward ?: false - ) - } - updateState(Timeline.Direction.BACKWARDS) { - it.copy( - hasMoreInCache = !builtEventsIdMap.containsKey(lastCacheEvent?.eventId), - hasReachedEnd = chunkEntity?.isLastBackward ?: false || lastCacheEvent?.root?.type == EventType.STATE_ROOM_CREATE - ) - } - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - * @return true if createSnapshot should be posted - */ - private fun paginateInternal(startDisplayIndex: Int?, - direction: Timeline.Direction, - count: Int): Boolean { - if (count == 0) { - return false - } - updateState(direction) { it.copy(requestedPaginationCount = count, isPaginating = true) } - val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong()) - val shouldFetchMore = builtCount < count && !hasReachedEnd(direction) - if (shouldFetchMore) { - val newRequestedCount = count - builtCount - updateState(direction) { it.copy(requestedPaginationCount = newRequestedCount) } - val fetchingCount = max(MIN_FETCHING_COUNT, newRequestedCount) - executePaginationTask(direction, fetchingCount) - } else { - updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } - } - return !shouldFetchMore - } - - private fun createSnapshot(): List { - return buildSendingEvents() + builtEvents.toList() - } - - private fun buildSendingEvents(): List { - val builtSendingEvents = mutableListOf() - if (hasReachedEnd(Timeline.Direction.FORWARDS) && !hasMoreInCache(Timeline.Direction.FORWARDS)) { - uiEchoManager.getInMemorySendingEvents() - .updateWithUiEchoInto(builtSendingEvents) - sendingEvents - .filter { timelineEvent -> - builtSendingEvents.none { it.eventId == timelineEvent.eventId } - } - .map { timelineEventMapper.map(it) } - .updateWithUiEchoInto(builtSendingEvents) - } - return builtSendingEvents - } - - private fun List.updateWithUiEchoInto(target: MutableList) { - target.addAll( - // Get most up to date send state (in memory) - map { uiEchoManager.updateSentStateWithUiEcho(it) } - ) - } - - private fun canPaginate(direction: Timeline.Direction): Boolean { - return isReady.get() && !getState(direction).isPaginating && hasMoreToLoad(direction) - } - - private fun getState(direction: Timeline.Direction): TimelineState { - return when (direction) { - Timeline.Direction.FORWARDS -> forwardsState.get() - Timeline.Direction.BACKWARDS -> backwardsState.get() - } - } - - private fun updateState(direction: Timeline.Direction, update: (TimelineState) -> TimelineState) { - val stateReference = when (direction) { - Timeline.Direction.FORWARDS -> forwardsState - Timeline.Direction.BACKWARDS -> backwardsState - } - val currentValue = stateReference.get() - val newValue = update(currentValue) - stateReference.set(newValue) - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - */ - private fun handleInitialLoad() { - var shouldFetchInitialEvent = false - val currentInitialEventId = initialEventId - val initialDisplayIndex = if (currentInitialEventId == null) { - timelineEvents.firstOrNull()?.displayIndex - } else { - val initialEvent = timelineEvents.where() - .equalTo(TimelineEventEntityFields.EVENT_ID, initialEventId) - .findFirst() - - shouldFetchInitialEvent = initialEvent == null - initialEvent?.displayIndex - } - prevDisplayIndex = initialDisplayIndex - nextDisplayIndex = initialDisplayIndex - if (currentInitialEventId != null && shouldFetchInitialEvent) { - fetchEvent(currentInitialEventId) - } else { - val count = timelineEvents.size.coerceAtMost(settings.initialSize) - if (initialEventId == null) { - paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count) - } else { - paginateInternal(initialDisplayIndex, Timeline.Direction.FORWARDS, (count / 2).coerceAtLeast(1)) - paginateInternal(initialDisplayIndex?.minus(1), Timeline.Direction.BACKWARDS, (count / 2).coerceAtLeast(1)) - } - } - postSnapshot() - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - */ - private fun handleUpdates(results: RealmResults, changeSet: OrderedCollectionChangeSet) { - // If changeSet has deletion we are having a gap, so we clear everything - if (changeSet.deletionRanges.isNotEmpty()) { - clearAllValues() - } - var postSnapshot = false - changeSet.insertionRanges.forEach { range -> - val (startDisplayIndex, direction) = if (range.startIndex == 0) { - Pair(results[range.length - 1]!!.displayIndex, Timeline.Direction.FORWARDS) - } else { - Pair(results[range.startIndex]!!.displayIndex, Timeline.Direction.BACKWARDS) - } - val state = getState(direction) - if (state.isPaginating) { - // We are getting new items from pagination - postSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedPaginationCount) - } else { - // We are getting new items from sync - buildTimelineEvents(startDisplayIndex, direction, range.length.toLong()) - postSnapshot = true - } - } - changeSet.changes.forEach { index -> - val eventEntity = results[index] - eventEntity?.eventId?.let { eventId -> - postSnapshot = rebuildEvent(eventId) { - buildTimelineEvent(eventEntity) - } || postSnapshot - } - } - if (postSnapshot) { - postSnapshot() - } - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - */ - private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { - val currentChunk = getLiveChunk() - val token = if (direction == Timeline.Direction.BACKWARDS) currentChunk?.prevToken else currentChunk?.nextToken - if (token == null) { - if (direction == Timeline.Direction.BACKWARDS || - (direction == Timeline.Direction.FORWARDS && currentChunk?.hasBeenALastForwardChunk().orFalse())) { - // We are in the case where event exists, but we do not know the token. - // Fetch (again) the last event to get a token - val lastKnownEventId = if (direction == Timeline.Direction.FORWARDS) { - timelineEvents.firstOrNull()?.eventId - } else { - timelineEvents.lastOrNull()?.eventId - } - if (lastKnownEventId == null) { - updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } - } else { - val params = FetchTokenAndPaginateTask.Params( - roomId = roomId, - limit = limit, - direction = direction.toPaginationDirection(), - lastKnownEventId = lastKnownEventId - ) - cancelableBag += fetchTokenAndPaginateTask - .configureWith(params) { - this.callback = createPaginationCallback(limit, direction) - } - .executeBy(taskExecutor) - } - } else { - updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } - } - } else { - val params = PaginationTask.Params( - roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit - ) - Timber.v("Should fetch $limit items $direction") - cancelableBag += paginationTask - .configureWith(params) { - this.callback = createPaginationCallback(limit, direction) - } - .executeBy(taskExecutor) - } - } - - // For debug purpose only - private fun dumpAndLogChunks() { - val liveChunk = getLiveChunk() - Timber.w("Live chunk: $liveChunk") - - Realm.getInstance(realmConfiguration).use { realm -> - ChunkEntity.where(realm, roomId).findAll() - .also { Timber.w("Found ${it.size} chunks") } - .forEach { - Timber.w("") - Timber.w("ChunkEntity: $it") - Timber.w("prevToken: ${it.prevToken}") - Timber.w("nextToken: ${it.nextToken}") - Timber.w("isLastBackward: ${it.isLastBackward}") - Timber.w("isLastForward: ${it.isLastForward}") - it.timelineEvents.forEach { tle -> - Timber.w(" TLE: ${tle.root?.content}") - } - } - } - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - */ - private fun getTokenLive(direction: Timeline.Direction): String? { - val chunkEntity = getLiveChunk() ?: return null - return if (direction == Timeline.Direction.BACKWARDS) chunkEntity.prevToken else chunkEntity.nextToken - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - * Return the current Chunk - */ - private fun getLiveChunk(): ChunkEntity? { - return timelineEvents.firstOrNull()?.chunk?.firstOrNull() - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - * @return the number of items who have been added - */ - private fun buildTimelineEvents(startDisplayIndex: Int?, - direction: Timeline.Direction, - count: Long): Int { - if (count < 1 || startDisplayIndex == null) { - return 0 - } - val start = System.currentTimeMillis() - val offsetResults = getOffsetResults(startDisplayIndex, direction, count) - if (offsetResults.isEmpty()) { - return 0 - } - val offsetIndex = offsetResults.last()!!.displayIndex - if (direction == Timeline.Direction.BACKWARDS) { - prevDisplayIndex = offsetIndex - 1 - } else { - nextDisplayIndex = offsetIndex + 1 - } - - // Prerequisite to in order for the ThreadsAwarenessHandler to work properly - fetchRootThreadEventsIfNeeded(offsetResults) - - offsetResults.forEach { eventEntity -> - - val timelineEvent = buildTimelineEvent(eventEntity) - val transactionId = timelineEvent.root.unsignedData?.transactionId - uiEchoManager.onSyncedEvent(transactionId) - - if (timelineEvent.isEncrypted() && - timelineEvent.root.mxDecryptionResult == null) { - timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineID)) } - } - - val position = if (direction == Timeline.Direction.FORWARDS) 0 else builtEvents.size - builtEvents.add(position, timelineEvent) - // Need to shift :/ - builtEventsIdMap.entries.filter { it.value >= position }.forEach { it.setValue(it.value + 1) } - builtEventsIdMap[eventEntity.eventId] = position - } - val time = System.currentTimeMillis() - start - Timber.v("Built ${offsetResults.size} items from db in $time ms") - // For the case where wo reach the lastForward chunk - updateLoadingStates(timelineEvents) - return offsetResults.size - } - - /** - * This function is responsible to fetch and store the root event of a thread event - * in order to be able to display the event to the user appropriately - */ - private fun fetchRootThreadEventsIfNeeded(offsetResults: RealmResults) = runBlocking { - val eventEntityList = offsetResults - .mapNotNull { - it?.root - }.map { - EventMapper.map(it) - } - threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(eventEntityList) - } - - private fun buildTimelineEvent(eventEntity: TimelineEventEntity): TimelineEvent { - return timelineEventMapper.map( - timelineEventEntity = eventEntity, - buildReadReceipts = settings.buildReadReceipts - ).let { timelineEvent -> - // eventually enhance with ui echo? - uiEchoManager.decorateEventWithReactionUiEcho(timelineEvent) ?: timelineEvent - } - } - - /** - * This has to be called on TimelineThread as it accesses realm live results - */ - private fun getOffsetResults(startDisplayIndex: Int, - direction: Timeline.Direction, - count: Long): RealmResults { - val offsetQuery = timelineEvents.where() - if (direction == Timeline.Direction.BACKWARDS) { - offsetQuery - .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) - .lessThanOrEqualTo(TimelineEventEntityFields.DISPLAY_INDEX, startDisplayIndex) - } else { - offsetQuery - .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) - .greaterThanOrEqualTo(TimelineEventEntityFields.DISPLAY_INDEX, startDisplayIndex) - } - return offsetQuery - .limit(count) - .findAll() - } - - private fun buildEventQuery(realm: Realm): RealmQuery { - return if (initialEventId == null) { - TimelineEventEntity - .whereRoomId(realm, roomId = roomId) - .equalTo(TimelineEventEntityFields.CHUNK.IS_LAST_FORWARD, true) - } else { - TimelineEventEntity - .whereRoomId(realm, roomId = roomId) - .`in`("${TimelineEventEntityFields.CHUNK.TIMELINE_EVENTS}.${TimelineEventEntityFields.EVENT_ID}", arrayOf(initialEventId)) - } - } - - private fun fetchEvent(eventId: String) { - val params = GetContextOfEventTask.Params(roomId, eventId) - cancelableBag += contextOfEventTask.configureWith(params) { - callback = object : MatrixCallback { - override fun onSuccess(data: TokenChunkEventPersistor.Result) { - postSnapshot() - } - - override fun onFailure(failure: Throwable) { - postFailure(failure) - } - } - } - .executeBy(taskExecutor) - } - - private fun postSnapshot() { - BACKGROUND_HANDLER.post { - if (isReady.get().not()) { - return@post - } - updateLoadingStates(timelineEvents) - val snapshot = createSnapshot() - val runnable = Runnable { - listeners.forEach { - it.onTimelineUpdated(snapshot) - } - } - debouncer.debounce("post_snapshot", runnable, 1) - } - } - - private fun postFailure(throwable: Throwable) { - if (isReady.get().not()) { - return - } - val runnable = Runnable { - listeners.forEach { - it.onTimelineFailure(throwable) - } - } - mainHandler.post(runnable) - } - - private fun clearAllValues() { - prevDisplayIndex = null - nextDisplayIndex = null - builtEvents.clear() - builtEventsIdMap.clear() - backwardsState.set(TimelineState()) - forwardsState.set(TimelineState()) - } - - private fun createPaginationCallback(limit: Int, direction: Timeline.Direction): MatrixCallback { - return object : MatrixCallback { - override fun onSuccess(data: TokenChunkEventPersistor.Result) { - when (data) { - TokenChunkEventPersistor.Result.SUCCESS -> { - Timber.v("Success fetching $limit items $direction from pagination request") - } - TokenChunkEventPersistor.Result.REACHED_END -> { - postSnapshot() - } - TokenChunkEventPersistor.Result.SHOULD_FETCH_MORE -> - // Database won't be updated, so we force pagination request - BACKGROUND_HANDLER.post { - executePaginationTask(direction, limit) - } - } - } - - override fun onFailure(failure: Throwable) { - updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } - postSnapshot() - Timber.v("Failure fetching $limit items $direction from pagination request") - } - } - } - - // Extension methods *************************************************************************** - - private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { - return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index 75e7e774df..126374b430 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -23,6 +23,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.realm.Sort import io.realm.kotlin.where +import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.session.events.model.isImageMessage import org.matrix.android.sdk.api.session.events.model.isVideoMessage import org.matrix.android.sdk.api.session.room.timeline.Timeline @@ -54,7 +55,8 @@ internal class DefaultTimelineService @AssistedInject constructor( private val timelineEventMapper: TimelineEventMapper, private val loadRoomMembersTask: LoadRoomMembersTask, private val threadsAwarenessHandler: ThreadsAwarenessHandler, - private val readReceiptHandler: ReadReceiptHandler + private val readReceiptHandler: ReadReceiptHandler, + private val coroutineDispatchers: MatrixCoroutineDispatchers ) : TimelineService { @AssistedFactory @@ -66,19 +68,18 @@ internal class DefaultTimelineService @AssistedInject constructor( return DefaultTimeline( roomId = roomId, initialEventId = eventId, + settings = settings, realmConfiguration = monarchy.realmConfiguration, - taskExecutor = taskExecutor, - contextOfEventTask = contextOfEventTask, + coroutineDispatchers = coroutineDispatchers, paginationTask = paginationTask, timelineEventMapper = timelineEventMapper, - settings = settings, timelineInput = timelineInput, eventDecryptor = eventDecryptor, fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, - realmSessionProvider = realmSessionProvider, loadRoomMembersTask = loadRoomMembersTask, - threadsAwarenessHandler = threadsAwarenessHandler, - readReceiptHandler = readReceiptHandler + readReceiptHandler = readReceiptHandler, + getEventTask = contextOfEventTask, + threadsAwarenessHandler = threadsAwarenessHandler ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadMoreResult.kt similarity index 76% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineState.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadMoreResult.kt index 0143d9bab3..c419e8325e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineState.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadMoreResult.kt @@ -16,9 +16,8 @@ package org.matrix.android.sdk.internal.session.room.timeline -internal data class TimelineState( - val hasReachedEnd: Boolean = false, - val hasMoreInCache: Boolean = true, - val isPaginating: Boolean = false, - val requestedPaginationCount: Int = 0 -) +internal enum class LoadMoreResult { + REACHED_END, + SUCCESS, + FAILURE +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt new file mode 100644 index 0000000000..528b564e8b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.timeline + +import io.realm.OrderedCollectionChangeSet +import io.realm.OrderedRealmCollectionChangeListener +import io.realm.Realm +import io.realm.RealmResults +import kotlinx.coroutines.CompletableDeferred +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.ChunkEntity +import org.matrix.android.sdk.internal.database.model.ChunkEntityFields +import org.matrix.android.sdk.internal.database.query.findAllIncludingEvents +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler +import java.util.concurrent.atomic.AtomicReference + +/** + * This class is responsible for keeping an instance of chunkEntity and timelineChunk according to the strategy. + * There is 2 different mode: Live and Permalink. + * In Live, we will query for the live chunk (isLastForward = true). + * In Permalink, we will query for the chunk including the eventId we are looking for. + * Once we got a ChunkEntity we wrap it with TimelineChunk class so we dispatch any methods for loading data. + */ + +internal class LoadTimelineStrategy( + private val roomId: String, + private val timelineId: String, + private val mode: Mode, + private val dependencies: Dependencies) { + + sealed interface Mode { + object Live : Mode + data class Permalink(val originEventId: String) : Mode + + fun originEventId(): String? { + return if (this is Permalink) { + originEventId + } else { + null + } + } + } + + data class Dependencies( + val timelineSettings: TimelineSettings, + val realm: AtomicReference, + val eventDecryptor: TimelineEventDecryptor, + val paginationTask: PaginationTask, + val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, + val getContextOfEventTask: GetContextOfEventTask, + val timelineInput: TimelineInput, + val timelineEventMapper: TimelineEventMapper, + val threadsAwarenessHandler: ThreadsAwarenessHandler, + val onEventsUpdated: (Boolean) -> Unit, + val onLimitedTimeline: () -> Unit, + val onNewTimelineEvents: (List) -> Unit + ) + + private var getContextLatch: CompletableDeferred? = null + private var chunkEntity: RealmResults? = null + private var timelineChunk: TimelineChunk? = null + + private val chunkEntityListener = OrderedRealmCollectionChangeListener { _: RealmResults, changeSet: OrderedCollectionChangeSet -> + // Can be call either when you open a permalink on an unknown event + // or when there is a gap in the timeline. + val shouldRebuildChunk = changeSet.insertions.isNotEmpty() + if (shouldRebuildChunk) { + timelineChunk?.close(closeNext = true, closePrev = true) + timelineChunk = chunkEntity?.createTimelineChunk() + // If we are waiting for a result of get context, post completion + getContextLatch?.complete(Unit) + // If we have a gap, just tell the timeline about it. + if (timelineChunk?.hasReachedLastForward().orFalse()) { + dependencies.onLimitedTimeline() + } + } + } + + private val uiEchoManagerListener = object : UIEchoManager.Listener { + override fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent?): Boolean { + return timelineChunk?.rebuildEvent(eventId, builder, searchInNext = true, searchInPrev = true).orFalse() + } + } + + private val timelineInputListener = object : TimelineInput.Listener { + + override fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent) { + if (roomId != this@LoadTimelineStrategy.roomId) { + return + } + if (uiEchoManager.onLocalEchoCreated(timelineEvent)) { + dependencies.onNewTimelineEvents(listOf(timelineEvent.eventId)) + dependencies.onEventsUpdated(false) + } + } + + override fun onLocalEchoUpdated(roomId: String, eventId: String, sendState: SendState) { + if (roomId != this@LoadTimelineStrategy.roomId) { + return + } + if (uiEchoManager.onSendStateUpdated(eventId, sendState)) { + dependencies.onEventsUpdated(false) + } + } + + override fun onNewTimelineEvents(roomId: String, eventIds: List) { + if (roomId == this@LoadTimelineStrategy.roomId && hasReachedLastForward()) { + dependencies.onNewTimelineEvents(eventIds) + } + } + } + + private val uiEchoManager = UIEchoManager(uiEchoManagerListener) + private val sendingEventsDataSource: SendingEventsDataSource = RealmSendingEventsDataSource( + roomId = roomId, + realm = dependencies.realm, + uiEchoManager = uiEchoManager, + timelineEventMapper = dependencies.timelineEventMapper, + onEventsUpdated = dependencies.onEventsUpdated + ) + + fun onStart() { + dependencies.eventDecryptor.start() + dependencies.timelineInput.listeners.add(timelineInputListener) + val realm = dependencies.realm.get() + sendingEventsDataSource.start() + chunkEntity = getChunkEntity(realm).also { + it.addChangeListener(chunkEntityListener) + timelineChunk = it.createTimelineChunk() + } + } + + fun onStop() { + dependencies.eventDecryptor.destroy() + dependencies.timelineInput.listeners.remove(timelineInputListener) + chunkEntity?.removeChangeListener(chunkEntityListener) + sendingEventsDataSource.stop() + timelineChunk?.close(closeNext = true, closePrev = true) + getContextLatch?.cancel() + chunkEntity = null + timelineChunk = null + } + + suspend fun loadMore(count: Int, direction: Timeline.Direction, fetchOnServerIfNeeded: Boolean = true): LoadMoreResult { + if (mode is Mode.Permalink && timelineChunk == null) { + val params = GetContextOfEventTask.Params(roomId, mode.originEventId) + try { + getContextLatch = CompletableDeferred() + dependencies.getContextOfEventTask.execute(params) + // waits for the query to be fulfilled + getContextLatch?.await() + getContextLatch = null + } catch (failure: Throwable) { + return LoadMoreResult.FAILURE + } + } + return timelineChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE + } + + fun getBuiltEventIndex(eventId: String): Int? { + return timelineChunk?.getBuiltEventIndex(eventId, searchInNext = true, searchInPrev = true) + } + + fun getBuiltEvent(eventId: String): TimelineEvent? { + return timelineChunk?.getBuiltEvent(eventId, searchInNext = true, searchInPrev = true) + } + + fun buildSnapshot(): List { + return buildSendingEvents() + timelineChunk?.builtItems(includesNext = true, includesPrev = true).orEmpty() + } + + private fun buildSendingEvents(): List { + return if (hasReachedLastForward()) { + sendingEventsDataSource.buildSendingEvents() + } else { + emptyList() + } + } + + private fun getChunkEntity(realm: Realm): RealmResults { + return if (mode is Mode.Permalink) { + ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) + } else { + ChunkEntity.where(realm, roomId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) + .findAll() + } + } + + private fun hasReachedLastForward(): Boolean { + return timelineChunk?.hasReachedLastForward().orFalse() + } + + private fun RealmResults.createTimelineChunk(): TimelineChunk? { + return firstOrNull()?.let { + return TimelineChunk( + chunkEntity = it, + timelineSettings = dependencies.timelineSettings, + roomId = roomId, + timelineId = timelineId, + eventDecryptor = dependencies.eventDecryptor, + paginationTask = dependencies.paginationTask, + fetchTokenAndPaginateTask = dependencies.fetchTokenAndPaginateTask, + timelineEventMapper = dependencies.timelineEventMapper, + uiEchoManager = uiEchoManager, + threadsAwarenessHandler = dependencies.threadsAwarenessHandler, + initialEventId = mode.originEventId(), + onBuiltEvents = dependencies.onEventsUpdated + ) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/SendingEventsDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/SendingEventsDataSource.kt new file mode 100644 index 0000000000..a98de1c595 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/SendingEventsDataSource.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.timeline + +import io.realm.Realm +import io.realm.RealmChangeListener +import io.realm.RealmList +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.query.where +import java.util.concurrent.atomic.AtomicReference + +internal interface SendingEventsDataSource { + fun start() + fun stop() + fun buildSendingEvents(): List +} + +internal class RealmSendingEventsDataSource( + private val roomId: String, + private val realm: AtomicReference, + private val uiEchoManager: UIEchoManager, + private val timelineEventMapper: TimelineEventMapper, + private val onEventsUpdated: (Boolean) -> Unit +) : SendingEventsDataSource { + + private var roomEntity: RoomEntity? = null + private var sendingTimelineEvents: RealmList? = null + private var frozenSendingTimelineEvents: RealmList? = null + + private val sendingTimelineEventsListener = RealmChangeListener> { events -> + uiEchoManager.onSentEventsInDatabase(events.map { it.eventId }) + frozenSendingTimelineEvents = sendingTimelineEvents?.freeze() + onEventsUpdated(false) + } + + override fun start() { + val safeRealm = realm.get() + roomEntity = RoomEntity.where(safeRealm, roomId = roomId).findFirst() + sendingTimelineEvents = roomEntity?.sendingTimelineEvents + sendingTimelineEvents?.addChangeListener(sendingTimelineEventsListener) + } + + override fun stop() { + sendingTimelineEvents?.removeChangeListener(sendingTimelineEventsListener) + sendingTimelineEvents = null + roomEntity = null + } + + override fun buildSendingEvents(): List { + val builtSendingEvents = mutableListOf() + uiEchoManager.getInMemorySendingEvents() + .addWithUiEcho(builtSendingEvents) + frozenSendingTimelineEvents + ?.filter { timelineEvent -> + builtSendingEvents.none { it.eventId == timelineEvent.eventId } + } + ?.map { + timelineEventMapper.map(it) + }?.addWithUiEcho(builtSendingEvents) + + return builtSendingEvents + } + + private fun List.addWithUiEcho(target: MutableList) { + target.addAll( + map { uiEchoManager.updateSentStateWithUiEcho(it) } + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt new file mode 100644 index 0000000000..14cba2a4b8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt @@ -0,0 +1,479 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.timeline + +import io.realm.OrderedCollectionChangeSet +import io.realm.OrderedRealmCollectionChangeListener +import io.realm.RealmObjectChangeListener +import io.realm.RealmQuery +import io.realm.RealmResults +import io.realm.Sort +import kotlinx.coroutines.CompletableDeferred +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.mapper.EventMapper +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.ChunkEntity +import org.matrix.android.sdk.internal.database.model.ChunkEntityFields +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler +import timber.log.Timber +import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean + +/** + * This is a wrapper around a ChunkEntity in the database. + * It does mainly listen to the db timeline events. + * It also triggers pagination to the server when needed, or dispatch to the prev or next chunk if any. + */ +internal class TimelineChunk(private val chunkEntity: ChunkEntity, + private val timelineSettings: TimelineSettings, + private val roomId: String, + private val timelineId: String, + private val eventDecryptor: TimelineEventDecryptor, + private val paginationTask: PaginationTask, + private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, + private val timelineEventMapper: TimelineEventMapper, + private val uiEchoManager: UIEchoManager? = null, + private val threadsAwarenessHandler: ThreadsAwarenessHandler, + private val initialEventId: String?, + private val onBuiltEvents: (Boolean) -> Unit) { + + private val isLastForward = AtomicBoolean(chunkEntity.isLastForward) + private val isLastBackward = AtomicBoolean(chunkEntity.isLastBackward) + private var prevChunkLatch: CompletableDeferred? = null + private var nextChunkLatch: CompletableDeferred? = null + + private val chunkObjectListener = RealmObjectChangeListener { _, changeSet -> + if (changeSet == null) return@RealmObjectChangeListener + if (changeSet.isDeleted.orFalse()) { + return@RealmObjectChangeListener + } + Timber.v("on chunk (${chunkEntity.identifier()}) changed: ${changeSet.changedFields?.joinToString(",")}") + if (changeSet.isFieldChanged(ChunkEntityFields.IS_LAST_FORWARD)) { + isLastForward.set(chunkEntity.isLastForward) + } + if (changeSet.isFieldChanged(ChunkEntityFields.IS_LAST_BACKWARD)) { + isLastBackward.set(chunkEntity.isLastBackward) + } + if (changeSet.isFieldChanged(ChunkEntityFields.NEXT_CHUNK.`$`)) { + nextChunk = createTimelineChunk(chunkEntity.nextChunk) + nextChunkLatch?.complete(Unit) + } + if (changeSet.isFieldChanged(ChunkEntityFields.PREV_CHUNK.`$`)) { + prevChunk = createTimelineChunk(chunkEntity.prevChunk) + prevChunkLatch?.complete(Unit) + } + } + + private val timelineEventsChangeListener = + OrderedRealmCollectionChangeListener { results: RealmResults, changeSet: OrderedCollectionChangeSet -> + Timber.v("on timeline events chunk update") + val frozenResults = results.freeze() + handleDatabaseChangeSet(frozenResults, changeSet) + } + + private var timelineEventEntities: RealmResults = chunkEntity.sortedTimelineEvents() + private val builtEvents: MutableList = Collections.synchronizedList(ArrayList()) + private val builtEventsIndexes: MutableMap = Collections.synchronizedMap(HashMap()) + + private var nextChunk: TimelineChunk? = null + private var prevChunk: TimelineChunk? = null + + init { + timelineEventEntities.addChangeListener(timelineEventsChangeListener) + chunkEntity.addChangeListener(chunkObjectListener) + } + + fun hasReachedLastForward(): Boolean { + return if (isLastForward.get()) { + true + } else { + nextChunk?.hasReachedLastForward().orFalse() + } + } + + fun builtItems(includesNext: Boolean, includesPrev: Boolean): List { + val deepBuiltItems = ArrayList(builtEvents.size) + if (includesNext) { + val nextEvents = nextChunk?.builtItems(includesNext = true, includesPrev = false).orEmpty() + deepBuiltItems.addAll(nextEvents) + } + deepBuiltItems.addAll(builtEvents) + if (includesPrev) { + val prevEvents = prevChunk?.builtItems(includesNext = false, includesPrev = true).orEmpty() + deepBuiltItems.addAll(prevEvents) + } + return deepBuiltItems + } + + /** + * This will take care of loading and building events of this chunk for the given direction and count. + * If @param fetchFromServerIfNeeded is true, it will try to fetch more events on server to get the right amount of data. + * This method will also post a snapshot as soon the data is built from db to avoid waiting for server response. + */ + suspend fun loadMore(count: Int, direction: Timeline.Direction, fetchOnServerIfNeeded: Boolean = true): LoadMoreResult { + if (direction == Timeline.Direction.FORWARDS && nextChunk != null) { + return nextChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE + } else if (direction == Timeline.Direction.BACKWARDS && prevChunk != null) { + return prevChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE + } + val loadFromStorageCount = loadFromStorage(count, direction) + Timber.v("Has loaded $loadFromStorageCount items from storage in $direction") + val offsetCount = count - loadFromStorageCount + return if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) { + LoadMoreResult.REACHED_END + } else if (direction == Timeline.Direction.BACKWARDS && isLastBackward.get()) { + LoadMoreResult.REACHED_END + } else if (offsetCount == 0) { + LoadMoreResult.SUCCESS + } else { + delegateLoadMore(fetchOnServerIfNeeded, offsetCount, direction) + } + } + + private suspend fun delegateLoadMore(fetchFromServerIfNeeded: Boolean, offsetCount: Int, direction: Timeline.Direction): LoadMoreResult { + return if (direction == Timeline.Direction.FORWARDS) { + val nextChunkEntity = chunkEntity.nextChunk + when { + nextChunkEntity != null -> { + if (nextChunk == null) { + nextChunk = createTimelineChunk(nextChunkEntity) + } + nextChunk?.loadMore(offsetCount, direction, fetchFromServerIfNeeded) ?: LoadMoreResult.FAILURE + } + fetchFromServerIfNeeded -> { + fetchFromServer(offsetCount, chunkEntity.nextToken, direction) + } + else -> { + LoadMoreResult.SUCCESS + } + } + } else { + val prevChunkEntity = chunkEntity.prevChunk + when { + prevChunkEntity != null -> { + if (prevChunk == null) { + prevChunk = createTimelineChunk(prevChunkEntity) + } + prevChunk?.loadMore(offsetCount, direction, fetchFromServerIfNeeded) ?: LoadMoreResult.FAILURE + } + fetchFromServerIfNeeded -> { + fetchFromServer(offsetCount, chunkEntity.prevToken, direction) + } + else -> { + LoadMoreResult.SUCCESS + } + } + } + } + + fun getBuiltEventIndex(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): Int? { + val builtEventIndex = builtEventsIndexes[eventId] + if (builtEventIndex != null) { + return getOffsetIndex() + builtEventIndex + } + if (searchInNext) { + val nextBuiltEventIndex = nextChunk?.getBuiltEventIndex(eventId, searchInNext = true, searchInPrev = false) + if (nextBuiltEventIndex != null) { + return nextBuiltEventIndex + } + } + if (searchInPrev) { + val prevBuiltEventIndex = prevChunk?.getBuiltEventIndex(eventId, searchInNext = false, searchInPrev = true) + if (prevBuiltEventIndex != null) { + return prevBuiltEventIndex + } + } + return null + } + + fun getBuiltEvent(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): TimelineEvent? { + val builtEventIndex = builtEventsIndexes[eventId] + if (builtEventIndex != null) { + return builtEvents.getOrNull(builtEventIndex) + } + if (searchInNext) { + val nextBuiltEvent = nextChunk?.getBuiltEvent(eventId, searchInNext = true, searchInPrev = false) + if (nextBuiltEvent != null) { + return nextBuiltEvent + } + } + if (searchInPrev) { + val prevBuiltEvent = prevChunk?.getBuiltEvent(eventId, searchInNext = false, searchInPrev = true) + if (prevBuiltEvent != null) { + return prevBuiltEvent + } + } + return null + } + + fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent?, searchInNext: Boolean, searchInPrev: Boolean): Boolean { + return tryOrNull { + val builtIndex = getBuiltEventIndex(eventId, searchInNext = false, searchInPrev = false) + if (builtIndex == null) { + val foundInPrev = searchInPrev && prevChunk?.rebuildEvent(eventId, builder, searchInNext = false, searchInPrev = true).orFalse() + if (foundInPrev) { + return true + } + if (searchInNext) { + return prevChunk?.rebuildEvent(eventId, builder, searchInPrev = false, searchInNext = true).orFalse() + } + return false + } + // Update the relation of existing event + builtEvents.getOrNull(builtIndex)?.let { te -> + val rebuiltEvent = builder(te) + builtEvents[builtIndex] = rebuiltEvent!! + true + } + } + ?: false + } + + fun close(closeNext: Boolean, closePrev: Boolean) { + if (closeNext) { + nextChunk?.close(closeNext = true, closePrev = false) + } + if (closePrev) { + prevChunk?.close(closeNext = false, closePrev = true) + } + nextChunk = null + nextChunkLatch?.cancel() + prevChunk = null + prevChunkLatch?.cancel() + chunkEntity.removeChangeListener(chunkObjectListener) + timelineEventEntities.removeChangeListener(timelineEventsChangeListener) + } + + /** + * This method tries to read events from the current chunk. + */ + private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): Int { + val displayIndex = getNextDisplayIndex(direction) ?: return 0 + val baseQuery = timelineEventEntities.where() + val timelineEvents = baseQuery.offsets(direction, count, displayIndex).findAll().orEmpty() + if (timelineEvents.isEmpty()) return 0 + fetchRootThreadEventsIfNeeded(timelineEvents) + if (direction == Timeline.Direction.FORWARDS) { + builtEventsIndexes.entries.forEach { it.setValue(it.value + timelineEvents.size) } + } + timelineEvents + .mapIndexed { index, timelineEventEntity -> + val timelineEvent = timelineEventEntity.buildAndDecryptIfNeeded() + if (timelineEvent.root.type == EventType.STATE_ROOM_CREATE) { + isLastBackward.set(true) + } + if (direction == Timeline.Direction.FORWARDS) { + builtEventsIndexes[timelineEvent.eventId] = index + builtEvents.add(index, timelineEvent) + } else { + builtEventsIndexes[timelineEvent.eventId] = builtEvents.size + builtEvents.add(timelineEvent) + } + } + return timelineEvents.size + } + + /** + * This function is responsible to fetch and store the root event of a thread event + * in order to be able to display the event to the user appropriately + */ + private suspend fun fetchRootThreadEventsIfNeeded(offsetResults: List) { + val eventEntityList = offsetResults + .mapNotNull { + it.root + }.map { + EventMapper.map(it) + } + threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(eventEntityList) + } + + private fun TimelineEventEntity.buildAndDecryptIfNeeded(): TimelineEvent { + val timelineEvent = buildTimelineEvent(this) + val transactionId = timelineEvent.root.unsignedData?.transactionId + uiEchoManager?.onSyncedEvent(transactionId) + if (timelineEvent.isEncrypted() && + timelineEvent.root.mxDecryptionResult == null) { + timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) } + } + return timelineEvent + } + + private fun buildTimelineEvent(eventEntity: TimelineEventEntity) = timelineEventMapper.map( + timelineEventEntity = eventEntity, + buildReadReceipts = timelineSettings.buildReadReceipts + ).let { + // eventually enhance with ui echo? + (uiEchoManager?.decorateEventWithReactionUiEcho(it) ?: it) + } + + /** + * Will try to fetch a new chunk on the home server. + * It will take care to update the database by inserting new events and linking new chunk + * with this one. + */ + private suspend fun fetchFromServer(count: Int, token: String?, direction: Timeline.Direction): LoadMoreResult { + val latch = if (direction == Timeline.Direction.FORWARDS) { + nextChunkLatch = CompletableDeferred() + nextChunkLatch + } else { + prevChunkLatch = CompletableDeferred() + prevChunkLatch + } + val loadMoreResult = try { + if (token == null) { + if (direction == Timeline.Direction.BACKWARDS || !chunkEntity.hasBeenALastForwardChunk()) return LoadMoreResult.REACHED_END + val lastKnownEventId = chunkEntity.sortedTimelineEvents().firstOrNull()?.eventId ?: return LoadMoreResult.FAILURE + val taskParams = FetchTokenAndPaginateTask.Params(roomId, lastKnownEventId, direction.toPaginationDirection(), count) + fetchTokenAndPaginateTask.execute(taskParams).toLoadMoreResult() + } else { + Timber.v("Fetch $count more events on server") + val taskParams = PaginationTask.Params(roomId, token, direction.toPaginationDirection(), count) + paginationTask.execute(taskParams).toLoadMoreResult() + } + } catch (failure: Throwable) { + Timber.e("Failed to fetch from server: $failure", failure) + LoadMoreResult.FAILURE + } + return if (loadMoreResult == LoadMoreResult.SUCCESS) { + latch?.await() + loadMore(count, direction, fetchOnServerIfNeeded = false) + } else { + loadMoreResult + } + } + + private fun TokenChunkEventPersistor.Result.toLoadMoreResult(): LoadMoreResult { + return when (this) { + TokenChunkEventPersistor.Result.REACHED_END -> LoadMoreResult.REACHED_END + TokenChunkEventPersistor.Result.SHOULD_FETCH_MORE, + TokenChunkEventPersistor.Result.SUCCESS -> LoadMoreResult.SUCCESS + } + } + + private fun getOffsetIndex(): Int { + var offset = 0 + var currentNextChunk = nextChunk + while (currentNextChunk != null) { + offset += currentNextChunk.builtEvents.size + currentNextChunk = currentNextChunk.nextChunk + } + return offset + } + + /** + * This method is responsible for managing insertions and updates of events on this chunk. + * + */ + private fun handleDatabaseChangeSet(frozenResults: RealmResults, changeSet: OrderedCollectionChangeSet) { + val insertions = changeSet.insertionRanges + for (range in insertions) { + val newItems = frozenResults + .subList(range.startIndex, range.startIndex + range.length) + .map { it.buildAndDecryptIfNeeded() } + builtEventsIndexes.entries.filter { it.value >= range.startIndex }.forEach { it.setValue(it.value + range.length) } + newItems.mapIndexed { index, timelineEvent -> + if (timelineEvent.root.type == EventType.STATE_ROOM_CREATE) { + isLastBackward.set(true) + } + val correctedIndex = range.startIndex + index + builtEvents.add(correctedIndex, timelineEvent) + builtEventsIndexes[timelineEvent.eventId] = correctedIndex + } + } + val modifications = changeSet.changeRanges + for (range in modifications) { + for (modificationIndex in (range.startIndex until range.startIndex + range.length)) { + val updatedEntity = frozenResults[modificationIndex] ?: continue + try { + builtEvents[modificationIndex] = updatedEntity.buildAndDecryptIfNeeded() + } catch (failure: Throwable) { + Timber.v("Fail to update items at index: $modificationIndex") + } + } + } + if (insertions.isNotEmpty() || modifications.isNotEmpty()) { + onBuiltEvents(true) + } + } + + private fun getNextDisplayIndex(direction: Timeline.Direction): Int? { + val frozenTimelineEvents = timelineEventEntities.freeze() + if (frozenTimelineEvents.isEmpty()) { + return null + } + return if (builtEvents.isEmpty()) { + if (initialEventId != null) { + frozenTimelineEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, initialEventId).findFirst()?.displayIndex + } else if (direction == Timeline.Direction.BACKWARDS) { + frozenTimelineEvents.first()?.displayIndex + } else { + frozenTimelineEvents.last()?.displayIndex + } + } else if (direction == Timeline.Direction.FORWARDS) { + builtEvents.first().displayIndex + 1 + } else { + builtEvents.last().displayIndex - 1 + } + } + + private fun createTimelineChunk(chunkEntity: ChunkEntity?): TimelineChunk? { + if (chunkEntity == null) return null + return TimelineChunk( + chunkEntity = chunkEntity, + timelineSettings = timelineSettings, + roomId = roomId, + timelineId = timelineId, + eventDecryptor = eventDecryptor, + paginationTask = paginationTask, + fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, + timelineEventMapper = timelineEventMapper, + uiEchoManager = uiEchoManager, + threadsAwarenessHandler = threadsAwarenessHandler, + initialEventId = null, + onBuiltEvents = this.onBuiltEvents + ) + } +} + +private fun RealmQuery.offsets( + direction: Timeline.Direction, + count: Int, + startDisplayIndex: Int +): RealmQuery { + sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + if (direction == Timeline.Direction.BACKWARDS) { + lessThanOrEqualTo(TimelineEventEntityFields.DISPLAY_INDEX, startDisplayIndex) + } else { + greaterThanOrEqualTo(TimelineEventEntityFields.DISPLAY_INDEX, startDisplayIndex) + } + return limit(count.toLong()) +} + +private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { + return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS +} + +private fun ChunkEntity.sortedTimelineEvents(): RealmResults { + return timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt index cdc85ea722..a953db0704 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt @@ -23,6 +23,9 @@ import javax.inject.Inject @SessionScope internal class TimelineInput @Inject constructor() { + + val listeners = mutableSetOf() + fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent) { listeners.toSet().forEach { it.onLocalEchoCreated(roomId, timelineEvent) } } @@ -35,8 +38,6 @@ internal class TimelineInput @Inject constructor() { listeners.toSet().forEach { it.onNewTimelineEvents(roomId, eventIds) } } - val listeners = mutableSetOf() - internal interface Listener { fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent) = Unit fun onLocalEchoUpdated(roomId: String, eventId: String, sendState: SendState) = Unit diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index dbcc37a918..4625155c0a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -25,94 +25,25 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addStateEvent import org.matrix.android.sdk.internal.database.helper.addTimelineEvent -import org.matrix.android.sdk.internal.database.helper.merge import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity -import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity -import org.matrix.android.sdk.internal.database.model.deleteOnCascade +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.find -import org.matrix.android.sdk.internal.database.query.findAllIncludingEvents -import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom -import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryEventsHelper import org.matrix.android.sdk.internal.util.awaitTransaction import timber.log.Timber import javax.inject.Inject /** - * Insert Chunk in DB, and eventually merge with existing chunk event + * Insert Chunk in DB, and eventually link next and previous chunk in db. */ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase private val monarchy: Monarchy) { - /** - *
-     * ========================================================================================================
-     * | Backward case                                                                                        |
-     * ========================================================================================================
-     *
-     *                               *--------------------------*        *--------------------------*
-     *                               | startToken1              |        | startToken1              |
-     *                               *--------------------------*        *--------------------------*
-     *                               |                          |        |                          |
-     *                               |                          |        |                          |
-     *                               |  receivedChunk backward  |        |                          |
-     *                               |         Events           |        |                          |
-     *                               |                          |        |                          |
-     *                               |                          |        |                          |
-     *                               |                          |        |                          |
-     * *--------------------------*  *--------------------------*        |                          |
-     * | startToken0              |  | endToken1                |   =>   |       Merged chunk       |
-     * *--------------------------*  *--------------------------*        |          Events          |
-     * |                          |                                      |                          |
-     * |                          |                                      |                          |
-     * |      Current Chunk       |                                      |                          |
-     * |         Events           |                                      |                          |
-     * |                          |                                      |                          |
-     * |                          |                                      |                          |
-     * |                          |                                      |                          |
-     * *--------------------------*                                      *--------------------------*
-     * | endToken0                |                                      | endToken0                |
-     * *--------------------------*                                      *--------------------------*
-     *
-     *
-     * ========================================================================================================
-     * | Forward case                                                                                         |
-     * ========================================================================================================
-     *
-     * *--------------------------*                                      *--------------------------*
-     * | startToken0              |                                      | startToken0              |
-     * *--------------------------*                                      *--------------------------*
-     * |                          |                                      |                          |
-     * |                          |                                      |                          |
-     * |      Current Chunk       |                                      |                          |
-     * |         Events           |                                      |                          |
-     * |                          |                                      |                          |
-     * |                          |                                      |                          |
-     * |                          |                                      |                          |
-     * *--------------------------*  *--------------------------*        |                          |
-     * | endToken0                |  | startToken1              |   =>   |       Merged chunk       |
-     * *--------------------------*  *--------------------------*        |          Events          |
-     *                               |                          |        |                          |
-     *                               |                          |        |                          |
-     *                               |  receivedChunk forward   |        |                          |
-     *                               |         Events           |        |                          |
-     *                               |                          |        |                          |
-     *                               |                          |        |                          |
-     *                               |                          |        |                          |
-     *                               *--------------------------*        *--------------------------*
-     *                               | endToken1                |        | endToken1                |
-     *                               *--------------------------*        *--------------------------*
-     *
-     * ========================================================================================================
-     * 
- */ - enum class Result { SHOULD_FETCH_MORE, REACHED_END, @@ -136,21 +67,21 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri prevToken = receivedChunk.end } + val existingChunk = ChunkEntity.find(realm, roomId, prevToken = prevToken, nextToken = nextToken) + if (existingChunk != null) { + Timber.v("This chunk is already in the db, returns") + return@awaitTransaction + } val prevChunk = ChunkEntity.find(realm, roomId, nextToken = prevToken) val nextChunk = ChunkEntity.find(realm, roomId, prevToken = nextToken) - - // The current chunk is the one we will keep all along the merge processChanges. - // We try to look for a chunk next to the token, - // otherwise we create a whole new one which is unlinked (not live) - val currentChunk = if (direction == PaginationDirection.FORWARDS) { - prevChunk?.apply { this.nextToken = nextToken } - } else { - nextChunk?.apply { this.prevToken = prevToken } + val currentChunk = ChunkEntity.create(realm, prevToken = prevToken, nextToken = nextToken).apply { + this.nextChunk = nextChunk + this.prevChunk = prevChunk } - ?: ChunkEntity.create(realm, prevToken, nextToken) - - if (receivedChunk.events.isNullOrEmpty() && !receivedChunk.hasMore()) { - handleReachEnd(realm, roomId, direction, currentChunk) + nextChunk?.prevChunk = currentChunk + prevChunk?.nextChunk = currentChunk + if (receivedChunk.events.isEmpty() && !receivedChunk.hasMore()) { + handleReachEnd(roomId, direction, currentChunk) } else { handlePagination(realm, roomId, direction, receivedChunk, currentChunk) } @@ -166,17 +97,10 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri } } - private fun handleReachEnd(realm: Realm, roomId: String, direction: PaginationDirection, currentChunk: ChunkEntity) { + private fun handleReachEnd(roomId: String, direction: PaginationDirection, currentChunk: ChunkEntity) { Timber.v("Reach end of $roomId") if (direction == PaginationDirection.FORWARDS) { - val currentLastForwardChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) - if (currentChunk != currentLastForwardChunk) { - currentChunk.isLastForward = true - currentLastForwardChunk?.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = false) - RoomSummaryEntity.where(realm, roomId).findFirst()?.apply { - latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) - } - } + Timber.v("We should keep the lastForward chunk unique, the one from sync") } else { currentChunk.isLastBackward = true } @@ -204,44 +128,50 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri roomMemberContentsByUser[stateEvent.stateKey] = stateEvent.content.toModel() } } - val eventIds = ArrayList(eventList.size) - eventList.forEach { event -> - if (event.eventId == null || event.senderId == null) { - return@forEach - } - val ageLocalTs = event.unsignedData?.age?.let { now - it } - eventIds.add(event.eventId) - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) - if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) { - val contentToUse = if (direction == PaginationDirection.BACKWARDS) { - event.prevContent - } else { - event.content + run processTimelineEvents@{ + eventList.forEach { event -> + if (event.eventId == null || event.senderId == null) { + return@forEach } - roomMemberContentsByUser[event.stateKey] = contentToUse.toModel() + // We check for the timeline event with this id + val eventId = event.eventId + val existingTimelineEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst() + // If it exists, we want to stop here, just link the prevChunk + val existingChunk = existingTimelineEvent?.chunk?.firstOrNull() + if (existingChunk != null) { + when (direction) { + PaginationDirection.BACKWARDS -> { + if (currentChunk.nextChunk == existingChunk) { + Timber.w("Avoid double link, shouldn't happen in an ideal world") + } else { + currentChunk.prevChunk = existingChunk + existingChunk.nextChunk = currentChunk + } + } + PaginationDirection.FORWARDS -> { + if (currentChunk.prevChunk == existingChunk) { + Timber.w("Avoid double link, shouldn't happen in an ideal world") + } else { + currentChunk.nextChunk = existingChunk + existingChunk.prevChunk = currentChunk + } + } + } + // Stop processing here + return@processTimelineEvents + } + val ageLocalTs = event.unsignedData?.age?.let { now - it } + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) + if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) { + val contentToUse = if (direction == PaginationDirection.BACKWARDS) { + event.prevContent + } else { + event.content + } + roomMemberContentsByUser[event.stateKey] = contentToUse.toModel() + } + currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) } - - currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) - } - // Find all the chunks which contain at least one event from the list of eventIds - val chunks = ChunkEntity.findAllIncludingEvents(realm, eventIds) - Timber.d("Found ${chunks.size} chunks containing at least one of the eventIds") - val chunksToDelete = ArrayList() - chunks.forEach { - if (it != currentChunk) { - Timber.d("Merge $it") - currentChunk.merge(roomId, it, direction) - chunksToDelete.add(it) - } - } - chunksToDelete.forEach { - it.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = false) - } - val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId) - val shouldUpdateSummary = roomSummaryEntity.latestPreviewableEvent == null || - (chunksToDelete.isNotEmpty() && currentChunk.isLastForward && direction == PaginationDirection.FORWARDS) - if (shouldUpdateSummary) { - roomSummaryEntity.latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) } if (currentChunk.isValid) { RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt index 4804fbd731..16d36c0cd9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt @@ -24,14 +24,10 @@ import org.matrix.android.sdk.api.session.room.model.ReactionAggregatedSummary import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import timber.log.Timber import java.util.Collections -internal class UIEchoManager( - private val settings: TimelineSettings, - private val listener: Listener -) { +internal class UIEchoManager(private val listener: Listener) { interface Listener { fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent?): Boolean @@ -70,13 +66,12 @@ internal class UIEchoManager( return existingState != sendState } - fun onLocalEchoCreated(timelineEvent: TimelineEvent) { - // Manage some ui echos (do it before filter because actual event could be filtered out) + fun onLocalEchoCreated(timelineEvent: TimelineEvent): Boolean { when (timelineEvent.root.getClearType()) { EventType.REDACTION -> { } EventType.REACTION -> { - val content = timelineEvent.root.content?.toModel() + val content: ReactionContent? = timelineEvent.root.content?.toModel() if (RelationType.ANNOTATION == content?.relatesTo?.type) { val reaction = content.relatesTo.key val relatedEventID = content.relatesTo.eventId @@ -96,11 +91,12 @@ internal class UIEchoManager( } Timber.v("On local echo created: ${timelineEvent.eventId}") inMemorySendingEvents.add(0, timelineEvent) + return true } - fun decorateEventWithReactionUiEcho(timelineEvent: TimelineEvent): TimelineEvent? { + fun decorateEventWithReactionUiEcho(timelineEvent: TimelineEvent): TimelineEvent { val relatedEventID = timelineEvent.eventId - val contents = inMemoryReactions[relatedEventID] ?: return null + val contents = inMemoryReactions[relatedEventID] ?: return timelineEvent var existingAnnotationSummary = timelineEvent.annotations ?: EventAnnotationsSummary( relatedEventID diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index 1a7e15e14c..f090975cad 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -345,15 +345,17 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle syncLocalTimestampMillis: Long, aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity { val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) + if (isLimited && lastChunk != null) { + lastChunk.deleteOnCascade(deleteStateEvents = true, canDeleteRoot = true) + } val chunkEntity = if (!isLimited && lastChunk != null) { lastChunk } else { - realm.createObject().apply { this.prevToken = prevToken } + realm.createObject().apply { + this.prevToken = prevToken + this.isLastForward = true + } } - // Only one chunk has isLastForward set to true - lastChunk?.isLastForward = false - chunkEntity.isLastForward = true - val eventIds = ArrayList(eventList.size) val roomMemberContentsByUser = HashMap() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt index 3faa0c9488..b6ea7a68f7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.isTokenError import org.matrix.android.sdk.api.logger.LoggerTag @@ -71,6 +72,7 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, private var isStarted = false private var isTokenValid = true private var retryNoNetworkTask: TimerTask? = null + private var previousSyncResponseHasToDevice = false private val activeCallListObserver = Observer> { activeCalls -> if (activeCalls.isEmpty() && backgroundDetectionObserver.isInBackground) { @@ -171,12 +173,15 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, if (state !is SyncState.Running) { updateStateTo(SyncState.Running(afterPause = true)) } - // No timeout after a pause - val timeout = state.let { if (it is SyncState.Running && it.afterPause) 0 else DEFAULT_LONG_POOL_TIMEOUT } + val timeout = when { + previousSyncResponseHasToDevice -> 0L /* Force timeout to 0 */ + state.let { it is SyncState.Running && it.afterPause } -> 0L /* No timeout after a pause */ + else -> DEFAULT_LONG_POOL_TIMEOUT + } Timber.tag(loggerTag.value).d("Execute sync request with timeout $timeout") val params = SyncTask.Params(timeout, SyncPresence.Online) val sync = syncScope.launch { - doSync(params) + previousSyncResponseHasToDevice = doSync(params) } runBlocking { sync.join() @@ -203,10 +208,14 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, } } - private suspend fun doSync(params: SyncTask.Params) { - try { + /** + * Will return true if the sync response contains some toDevice events. + */ + private suspend fun doSync(params: SyncTask.Params): Boolean { + return try { val syncResponse = syncTask.execute(params) _syncFlow.emit(syncResponse) + syncResponse.toDevice?.events?.isNotEmpty().orFalse() } catch (failure: Throwable) { if (failure is Failure.NetworkConnection) { canReachServer = false @@ -229,6 +238,7 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, delay(RETRY_WAIT_TIME_MS) } } + false } finally { state.let { if (it is SyncState.Running && it.afterPause) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt index 763cd55714..2f1241f4d8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt @@ -20,6 +20,7 @@ import androidx.work.BackoffPolicy import androidx.work.ExistingWorkPolicy import androidx.work.WorkerParameters import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.failure.isTokenError import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.di.WorkManagerProvider @@ -34,8 +35,8 @@ import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject -private const val DEFAULT_LONG_POOL_TIMEOUT = 6L -private const val DEFAULT_DELAY_TIMEOUT = 30_000L +private const val DEFAULT_LONG_POOL_TIMEOUT_SECONDS = 6L +private const val DEFAULT_DELAY_MILLIS = 30_000L /** * Possible previous worker: None @@ -47,9 +48,12 @@ internal class SyncWorker(context: Context, workerParameters: WorkerParameters, @JsonClass(generateAdapter = true) internal data class Params( override val sessionId: String, - val timeout: Long = DEFAULT_LONG_POOL_TIMEOUT, - val delay: Long = DEFAULT_DELAY_TIMEOUT, + // In seconds + val timeout: Long = DEFAULT_LONG_POOL_TIMEOUT_SECONDS, + // In milliseconds + val delay: Long = DEFAULT_DELAY_MILLIS, val periodic: Boolean = false, + val forceImmediate: Boolean = false, override val lastFailureMessage: String? = null ) : SessionWorkerParams @@ -65,13 +69,26 @@ internal class SyncWorker(context: Context, workerParameters: WorkerParameters, Timber.i("Sync work starting") return runCatching { - doSync(params.timeout) + doSync(if (params.forceImmediate) 0 else params.timeout) }.fold( - { + { hasToDeviceEvents -> Result.success().also { if (params.periodic) { - // we want to schedule another one after delay - automaticallyBackgroundSync(workManagerProvider, params.sessionId, params.timeout, params.delay) + // we want to schedule another one after a delay, or immediately if hasToDeviceEvents + automaticallyBackgroundSync( + workManagerProvider = workManagerProvider, + sessionId = params.sessionId, + serverTimeoutInSeconds = params.timeout, + delayInSeconds = params.delay, + forceImmediate = hasToDeviceEvents + ) + } else if (hasToDeviceEvents) { + // Previous response has toDevice events, request an immediate sync request + requireBackgroundSync( + workManagerProvider = workManagerProvider, + sessionId = params.sessionId, + serverTimeoutInSeconds = 0 + ) } } }, @@ -92,16 +109,29 @@ internal class SyncWorker(context: Context, workerParameters: WorkerParameters, return params.copy(lastFailureMessage = params.lastFailureMessage ?: message) } - private suspend fun doSync(timeout: Long) { + /** + * Will return true if the sync response contains some toDevice events. + */ + private suspend fun doSync(timeout: Long): Boolean { val taskParams = SyncTask.Params(timeout * 1000, SyncPresence.Offline) - syncTask.execute(taskParams) + val syncResponse = syncTask.execute(taskParams) + return syncResponse.toDevice?.events?.isNotEmpty().orFalse() } companion object { private const val BG_SYNC_WORK_NAME = "BG_SYNCP" - fun requireBackgroundSync(workManagerProvider: WorkManagerProvider, sessionId: String, serverTimeout: Long = 0) { - val data = WorkerParamsFactory.toData(Params(sessionId, serverTimeout, 0L, false)) + fun requireBackgroundSync(workManagerProvider: WorkManagerProvider, + sessionId: String, + serverTimeoutInSeconds: Long = 0) { + val data = WorkerParamsFactory.toData( + Params( + sessionId = sessionId, + timeout = serverTimeoutInSeconds, + delay = 0L, + periodic = false + ) + ) val workRequest = workManagerProvider.matrixOneTimeWorkRequestBuilder() .setConstraints(WorkManagerProvider.workConstraints) .setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY_MILLIS, TimeUnit.MILLISECONDS) @@ -111,13 +141,24 @@ internal class SyncWorker(context: Context, workerParameters: WorkerParameters, .enqueueUniqueWork(BG_SYNC_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest) } - fun automaticallyBackgroundSync(workManagerProvider: WorkManagerProvider, sessionId: String, serverTimeout: Long = 0, delayInSeconds: Long = 30) { - val data = WorkerParamsFactory.toData(Params(sessionId, serverTimeout, delayInSeconds, true)) + fun automaticallyBackgroundSync(workManagerProvider: WorkManagerProvider, + sessionId: String, + serverTimeoutInSeconds: Long = 0, + delayInSeconds: Long = 30, + forceImmediate: Boolean = false) { + val data = WorkerParamsFactory.toData( + Params( + sessionId = sessionId, + timeout = serverTimeoutInSeconds, + delay = delayInSeconds, + forceImmediate = forceImmediate + ) + ) val workRequest = workManagerProvider.matrixOneTimeWorkRequestBuilder() .setConstraints(WorkManagerProvider.workConstraints) .setInputData(data) .setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY_MILLIS, TimeUnit.MILLISECONDS) - .setInitialDelay(delayInSeconds, TimeUnit.SECONDS) + .setInitialDelay(if (forceImmediate) 0 else delayInSeconds, TimeUnit.SECONDS) .build() // Avoid risking multiple chains of syncs by replacing the existing chain workManagerProvider.workManager diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/terms/DefaultTermsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/terms/DefaultTermsService.kt index c52c6a404e..313fb6319d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/terms/DefaultTermsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/terms/DefaultTermsService.kt @@ -36,6 +36,7 @@ import org.matrix.android.sdk.internal.session.sync.model.accountdata.AcceptedTe import org.matrix.android.sdk.internal.session.user.accountdata.UpdateUserAccountDataTask import org.matrix.android.sdk.internal.session.user.accountdata.UserAccountDataDataSource import org.matrix.android.sdk.internal.util.ensureTrailingSlash +import timber.log.Timber import javax.inject.Inject internal class DefaultTermsService @Inject constructor( @@ -63,19 +64,28 @@ internal class DefaultTermsService @Inject constructor( */ override suspend fun getHomeserverTerms(baseUrl: String): TermsResponse { return try { + val request = baseUrl + NetworkConstants.URI_API_PREFIX_PATH_R0 + "register" executeRequest(null) { - termsAPI.register(baseUrl + NetworkConstants.URI_API_PREFIX_PATH_R0 + "register") + termsAPI.register(request) } // Return empty result if it succeed, but it should never happen + Timber.w("Request $request succeeded, it should never happen") TermsResponse() } catch (throwable: Throwable) { - @Suppress("UNCHECKED_CAST") - TermsResponse( - policies = (throwable.toRegistrationFlowResponse() - ?.params - ?.get(LoginFlowTypes.TERMS) as? JsonDict) - ?.get("policies") as? JsonDict - ) + val registrationFlowResponse = throwable.toRegistrationFlowResponse() + if (registrationFlowResponse != null) { + @Suppress("UNCHECKED_CAST") + TermsResponse( + policies = (registrationFlowResponse + .params + ?.get(LoginFlowTypes.TERMS) as? JsonDict) + ?.get("policies") as? JsonDict + ) + } else { + // Other error + Timber.e(throwable, "Error while getting homeserver terms") + throw throwable + } } } diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 6ca86be095..9db73230ee 100644 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -160,7 +160,7 @@ Formatter\.formatShortFileSize===1 # android\.text\.TextUtils ### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt -enum class===114 +enum class===118 ### Do not import temporary legacy classes import org.matrix.android.sdk.internal.legacy.riot===3 diff --git a/tools/release/sign_apk.sh b/tools/release/sign_apk.sh index aae9e1a378..de5a22dd34 100755 --- a/tools/release/sign_apk.sh +++ b/tools/release/sign_apk.sh @@ -17,7 +17,7 @@ PARAM_KEYSTORE_PATH=$1 PARAM_APK=$2 # Other params -BUILD_TOOLS_VERSION="31.0.0-rc5" +BUILD_TOOLS_VERSION="31.0.0" MIN_SDK_VERSION=21 echo "Signing APK with build-tools version ${BUILD_TOOLS_VERSION} for min SDK version ${MIN_SDK_VERSION}..." diff --git a/tools/release/sign_apk_unsafe.sh b/tools/release/sign_apk_unsafe.sh index 5d209a4a2b..a7536616e9 100755 --- a/tools/release/sign_apk_unsafe.sh +++ b/tools/release/sign_apk_unsafe.sh @@ -23,7 +23,7 @@ PARAM_KS_PASS=$3 PARAM_KEY_PASS=$4 # Other params -BUILD_TOOLS_VERSION="31.0.0-rc5" +BUILD_TOOLS_VERSION="31.0.0" MIN_SDK_VERSION=21 echo "Signing APK with build-tools version ${BUILD_TOOLS_VERSION} for min SDK version ${MIN_SDK_VERSION}..." diff --git a/vector/build.gradle b/vector/build.gradle index a578fdb52f..2be5fa984d 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -15,7 +15,7 @@ kapt { // Note: 2 digits max for each value ext.versionMajor = 1 ext.versionMinor = 3 -ext.versionPatch = 10 +ext.versionPatch = 13 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -140,7 +140,7 @@ android { buildConfigField "String", "BUILD_NUMBER", "\"${buildNumber}\"" resValue "string", "build_number", "\"${buildNumber}\"" - buildConfigField "im.vector.app.features.VectorFeatures.LoginVersion", "LOGIN_VERSION", "im.vector.app.features.VectorFeatures.LoginVersion.V1" + buildConfigField "im.vector.app.features.VectorFeatures.OnboardingVariant", "ONBOARDING_VARIANT", "im.vector.app.features.VectorFeatures.OnboardingVariant.FTUE_AUTH" buildConfigField "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy", "outboundSessionKeySharingStrategy", "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy.WhenTyping" @@ -359,7 +359,7 @@ dependencies { implementation 'com.facebook.stetho:stetho:1.6.0' // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.39' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.40' // FlowBinding implementation libs.github.flowBinding @@ -389,6 +389,8 @@ dependencies { implementation libs.google.material implementation 'me.gujun.android:span:1.7' implementation libs.markwon.core + implementation libs.markwon.extLatex + implementation libs.markwon.inlineParser implementation libs.markwon.html implementation 'com.googlecode.htmlcompressor:htmlcompressor:1.5.2' implementation 'me.saket:better-link-movement-method:2.2.0' @@ -452,7 +454,7 @@ dependencies { // OSS License, gplay flavor only gplayImplementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' - implementation "androidx.emoji2:emoji2:1.0.0" + implementation "androidx.emoji2:emoji2:1.0.1" implementation('com.github.BillCarsonFr:JsonViewer:0.7') // WebRTC diff --git a/vector/lint.xml b/vector/lint.xml index 9d9b208df7..818349da24 100644 --- a/vector/lint.xml +++ b/vector/lint.xml @@ -72,6 +72,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +