Merge branch 'develop' into feature/ons/poll_timeline

* develop: (88 commits)
  Fix "PendingIntents attached to actions with remote inputs must be mutable" Room notifications are now working on Android 12 emulator
  Move P1 issues to app team and crypto boards
  Quick fix on this file
  Fix warning after rebase (it's for test, so OK to suppress warning)
  Update changelog, since the feature is not visible yet.
  Create file for Toast style (more generic) And improve fragment_create_poll.xml preview rendering
  Create CallToAction button style
  Create dedicated file for TextInputLayout styles And follow naming convention
  Lint fix.
  Add comment to run on Android 12
  Use correct value, but I do not see any effect on emulator with API 12
  Fix crash on Android 12. I guess we accept only images coming from the keyboard.
  ktlint
  Fix lint issue "NullSafeMutableLiveData"
  Fix lint issue "Incorrect constant"
  Fix lint issue "Outside Range" Ensure that column index is not -1
  Make the Cursor extensions public And make the code more efficient, since we call getColumnIndexOrNull only once and not on each cursor iteration
  Fix crash on Android 12: PendingIntent.FLAG_IMMUTABLE has to be set
  Fix crash on Android 12 java.lang.SecurityException: To use the sampling rate of 0 microseconds, app needs to declare the normal permission HIGH_SAMPLING_RATE_SENSORS.
  InputConnectionCompat.createWrapper is deprecated Permission should be granted, according to https://developer.android.com/reference/android/view/OnReceiveContentListener#uri-permissions
  ...
This commit is contained in:
Onuray Sahin 2021-11-16 16:14:17 +03:00
commit eb1519743d
102 changed files with 1653 additions and 739 deletions

View file

@ -23,7 +23,7 @@ body:
- type: textarea
id: result
attributes:
label: Intended result and actual result
label: Outcome
placeholder: Tell us what went wrong
value: |
#### What did you expect?

View file

@ -10,7 +10,7 @@ body:
id: usecase
attributes:
label: Your use case
description: What would you like to be able to do? Please feel welcome to include screenshots or mock ups.
description: Please feel welcome to include screenshots or mock ups.
placeholder: Tell us what you would like to do!
value: |
#### What would you like to do?

View file

@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
api-level: [28]
api-level: [ 29 ]
steps:
- uses: actions/checkout@v2
with:
@ -56,7 +56,24 @@ jobs:
java-version: '11'
- name: Run sanity tests on API ${{ matrix.api-level }}
uses: reactivecircus/android-emulator-runner@v2
continue-on-error: true # allow pipeline to upload failure results
with:
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
api-level: ${{ matrix.api-level }}
script: ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest
emulator-build: 7425822 # workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160
script: |
adb root
adb logcat -c
touch emulator.log
chmod 777 emulator.log
adb logcat >> emulator.log &
./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots
- name: Upload Failing Test Report Log
if: failure()
uses: actions/upload-artifact@v2
with:
name: sanity-error-results
path: |
emulator.log
failure_screenshots/

View file

@ -8,7 +8,7 @@ jobs:
automate-project-columns:
runs-on: ubuntu-latest
steps:
- uses: alex-page/github-project-automation-plus@v0.8.1
- uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
with:
project: Issue triage
column: Incoming

View file

@ -0,0 +1,101 @@
name: Move labelled issues to correct boards and columns
on:
issues:
types: [labeled]
jobs:
move_needs_info_issues:
name: Move X-Needs-Info issues to Need info on triage board
runs-on: ubuntu-latest
steps:
- uses: konradpabjan/move-labeled-or-milestoned-issue@219d384e03fa4b6460cd24f9f37d19eb033a4338
with:
action-token: "${{ secrets.ELEMENT_BOT_TOKEN }}"
project-url: "https://github.com/vector-im/element-android/projects/4"
column-name: "Need info"
label-name: "X-Needs-Info"
add_priority_design_issues_to_project:
name: Move priority X-Needs-Design issues to Design project board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Design') &&
(contains(github.event.issue.labels.*.name, 'O-Frequent') ||
contains(github.event.issue.labels.*.name, 'O-Occasional')) &&
(contains(github.event.issue.labels.*.name, 'S-Critical') ||
contains(github.event.issue.labels.*.name, 'S-Major') ||
contains(github.event.issue.labels.*.name, 'S-Minor'))
steps:
- uses: octokit/graphql-action@v2.x
id: add_to_project
with:
headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: |
mutation add_to_project($projectid:String!,$contentid:String!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) {
projectNextItem {
id
}
}
}
projectid: ${{ env.PROJECT_ID }}
contentid: ${{ github.event.issue.node_id }}
env:
PROJECT_ID: "PN_kwDOAM0swc0sUA"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
move_spaces_issues:
name: Move Spaces issues to 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: konradpabjan/move-labeled-or-milestoned-issue@219d384e03fa4b6460cd24f9f37d19eb033a4338
with:
action-token: "${{ secrets.ELEMENT_BOT_TOKEN }}"
project-url: "https://github.com/orgs/vector-im/projects/6"
column-name: "📥 Inbox"
label-name: "A-Spaces"
- uses: octokit/graphql-action@v2.x
id: add_to_delight2
with:
headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: |
mutation add_to_project($projectid:String!,$contentid:String!) {
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: Move A-Voice Messages to Voice message board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'A-Voice Messages')
steps:
- uses: octokit/graphql-action@v2.x
with:
headers: '{"GraphQL-Features": "projects_next_graphql"}'
query: |
mutation add_to_project($projectid:String!,$contentid:String!) {
addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) {
projectNextItem {
id
}
}
}
projectid: ${{ env.PROJECT_ID }}
contentid: ${{ github.event.issue.node_id }}
env:
PROJECT_ID: "PN_kwDOAM0swc2KCw"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

View file

@ -0,0 +1,35 @@
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
if: >
${{
!contains(github.event.issue.labels.*.name, 'X-Needs-Info') }}
env:
BOARD_NAME: "Issue triage"
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.event.repository.name }}
ISSUE: ${{ github.event.issue.number }}
steps:
- name: Check if issue is already in "${{ env.BOARD_NAME }}"
run: |
if curl -i -H 'Content-Type: application/json' -H "Authorization: bearer ${{ secrets.GITHUB_TOKEN }}" -X POST -d '{"query": "query($issue: Int!, $owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { projectCards { nodes { project { name } } } } } } ", "variables" : "{ \"issue\": '${ISSUE}', \"owner\": \"'${OWNER}'\", \"repo\": \"'${REPO}'\" }" }' https://api.github.com/graphql | grep "\b$BOARD_NAME\b"; then
echo "Issue is already in Project '$BOARD_NAME', proceeding";
echo "ALREADY_IN_BOARD=true" >> $GITHUB_ENV
else
echo "Issue is not in project '$BOARD_NAME', cancelling this workflow"
echo "ALREADY_IN_BOARD=false" >> $GITHUB_ENV
fi
- name: Move issue
uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
if: ${{ env.ALREADY_IN_BOARD == 'true' }}
with:
project: Issue triage
column: Triaged
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}

View file

@ -1,16 +0,0 @@
name: Move X-Needs-Info into Need info column in the Issue triage board
on:
issues:
types: [labeled]
jobs:
Move_Labeled_Issue_On_Project_Board:
runs-on: ubuntu-latest
steps:
- uses: konradpabjan/move-labeled-or-milestoned-issue@v2.0
with:
action-token: ${{ secrets.GITHUB_TOKEN }}
project-url: "https://github.com/vector-im/element-android/projects/4"
column-name: "Need info"
label-name: "X-Needs-Info"

View file

@ -0,0 +1,55 @@
name: Move P1 issues into the P1 column for the App Team and Crypto team
on:
issues:
types: [labeled, unlabeled]
jobs:
p1_issues_to_team_workboard:
runs-on: ubuntu-latest
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') ||
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'))
steps:
- uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
with:
project: Android App Team
column: P1
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
P1_issues_to_crypto_team_workboard:
runs-on: ubuntu-latest
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') ||
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'))
steps:
- uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
with:
project: Crypto Team
column: Ready
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}

View file

@ -17,6 +17,7 @@
package im.vector.lib.attachmentviewer
import android.annotation.SuppressLint
import android.graphics.Color
import android.os.Build
import android.os.Bundle
@ -141,7 +142,12 @@ abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventLi
// New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
window.setDecorFitsSystemWindows(false)
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
@SuppressLint("WrongConstant")
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
}
// New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar)
// new API instead of FLAG_TRANSLUCENT_NAVIGATION
@ -347,7 +353,12 @@ abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventLi
// new API instead of SYSTEM_UI_FLAG_HIDE_NAVIGATION
window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
@SuppressLint("WrongConstant")
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
}
// New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar)
// New API instead of FLAG_TRANSLUCENT_NAVIGATION

1
changelog.d/4257.misc Normal file
View file

@ -0,0 +1 @@
Remove redundant text in feature request issue form

View file

@ -1 +1 @@
Poll Feature - Create Poll Screen
Poll Feature - Create Poll Screen (Disabled for now)

1
changelog.d/4401.removal Normal file
View file

@ -0,0 +1 @@
Breaking SDK API change to PushRuleListener, the separated callbacks have been merged into one with a data class which includes all the previously separated push information

1
changelog.d/4402.feature Normal file
View file

@ -0,0 +1 @@
Adds support for images inside message notifications

1
changelog.d/4424.bugfix Normal file
View file

@ -0,0 +1 @@
Fix incorrect cropping of conversation icons

1
changelog.d/4435.misc Normal file
View file

@ -0,0 +1 @@
Add and improve issue triage workflows

1
changelog.d/4446.bugfix Normal file
View file

@ -0,0 +1 @@
Unable to establish Olm outbound session from fallback key

1
changelog.d/4452.misc Normal file
View file

@ -0,0 +1 @@
Update issue template to bring in line with element-web

View file

@ -1,8 +1,8 @@
ext.versions = [
'minSdk' : 21,
'compileSdk' : 30,
'targetSdk' : 30,
'compileSdk' : 31,
'targetSdk' : 31,
'sourceCompat' : JavaVersion.VERSION_11,
'targetCompat' : JavaVersion.VERSION_11,
]
@ -11,12 +11,12 @@ def gradle = "7.0.3"
// Ref: https://kotlinlang.org/releases.html
def kotlin = "1.5.31"
def kotlinCoroutines = "1.5.2"
def dagger = "2.40"
def dagger = "2.40.1"
def retrofit = "2.9.0"
def arrow = "0.8.2"
def markwon = "4.6.2"
def moshi = "1.12.0"
def lifecycle = "2.2.0"
def lifecycle = "2.4.0"
def flowBinding = "1.2.0"
def epoxy = "4.6.2"
def mavericks = "2.4.0"
@ -46,18 +46,18 @@ ext.libs = [
],
androidx : [
'appCompat' : "androidx.appcompat:appcompat:1.3.1",
'core' : "androidx.core:core-ktx:1.6.0",
'core' : "androidx.core:core-ktx:1.7.0",
'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1",
'exifinterface' : "androidx.exifinterface:exifinterface:1.3.3",
'fragmentKtx' : "androidx.fragment:fragment-ktx:1.3.6",
'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.1",
'work' : "androidx.work:work-runtime-ktx:2.6.0",
'work' : "androidx.work:work-runtime-ktx:2.7.0",
'autoFill' : "androidx.autofill:autofill:1.1.0",
'preferenceKtx' : "androidx.preference:preference-ktx:1.1.1",
'junit' : "androidx.test.ext:junit:1.1.3",
'lifecycleExtensions' : "androidx.lifecycle:lifecycle-extensions:$lifecycle",
'lifecycleJava8' : "androidx.lifecycle:lifecycle-common-java8:$lifecycle",
'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1",
'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle",
'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle",
'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle",
'datastore' : "androidx.datastore:datastore:1.0.0",
'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0",
'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2",

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=a8da5b02437a60819cad23e10fc7e9cf32bcb57029d9cb277e26eeff76ce014b
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip
distributionSha256Sum=00b273629df4ce46e68df232161d5a7c4e495b9a029ce6e0420f071e21316867
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View file

@ -129,9 +129,9 @@
<color name="vctr_chat_effect_snow_background_light">@color/black_alpha</color>
<color name="vctr_chat_effect_snow_background_dark">@android:color/transparent</color>
<attr name="vctr_voice_message_toast_background" format="color" />
<color name="vctr_voice_message_toast_background_light">@color/palette_black_900</color>
<color name="vctr_voice_message_toast_background_dark">@color/palette_gray_400</color>
<attr name="vctr_toast_background" format="color" />
<color name="vctr_toast_background_light">@color/palette_black_900</color>
<color name="vctr_toast_background_dark">@color/palette_gray_400</color>
<!-- Presence Indicator colors -->
<attr name="vctr_presence_indicator_offline" format="color" />

View file

@ -10,6 +10,11 @@
<item name="lineHeight">24sp</item>
</style>
<style name="Widget.Vector.Button.CallToAction" parent="Widget.Vector.Button">
<item name="android:backgroundTint">@color/button_background_tint_selector</item>
<item name="android:textColor">@android:color/white</item>
</style>
<style name="Widget.Vector.Button.Destructive">
<item name="android:minWidth">94dp</item>
<item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayDestructive</item>

View file

@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Default style for TextInputLayout -->
<style name="Widget.Vector.TextInputLayout" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox" />
<style name="Widget.Vector.TextInputLayout.Password">
<item name="endIconMode">password_toggle</item>
<item name="endIconTint">?vctr_content_secondary</item>
</style>
<style name="Widget.Vector.EditText.Composer" parent="Widget.AppCompat.EditText">
<item name="android:background">@android:color/transparent</item>
<item name="android:inputType">textCapSentences|textMultiLine</item>
@ -19,9 +11,4 @@
<item name="android:textColor">?vctr_message_text_color</item>
</style>
<style name="Widget.Vector.EditText.Form" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<item name="boxStrokeColor">@color/form_edit_text_stroke_color_selector</item>
<item name="android:textColorHint">@color/form_edit_text_hint_color_selector</item>
</style>
</resources>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Widget.Vector.Button.CreatePoll" parent="Widget.Vector.Button">
<item name="android:backgroundTint">@color/button_background_tint_selector</item>
<item name="android:textAppearance">@style/TextAppearance.Vector.Button</item>
<item name="android:textColor">@android:color/white</item>
</style>
</resources>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Default style for TextInputLayout -->
<style name="Widget.Vector.TextInputLayout" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox" />
<style name="Widget.Vector.TextInputLayout.Password">
<item name="endIconMode">password_toggle</item>
<item name="endIconTint">?vctr_content_secondary</item>
</style>
<style name="Widget.Vector.TextInputLayout.Form">
<item name="boxStrokeColor">@color/form_edit_text_stroke_color_selector</item>
<item name="android:textColorHint">@color/form_edit_text_hint_color_selector</item>
</style>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Widget.Vector.TextView.Caption.Toast">
<item name="android:paddingTop">8dp</item>
<item name="android:paddingBottom">8dp</item>
<item name="android:paddingStart">12dp</item>
<item name="android:paddingEnd">12dp</item>
<item name="android:background">@drawable/bg_round_corner_8dp</item>
<item name="android:backgroundTint">?vctr_toast_background</item>
<item name="android:textColor">@color/palette_white</item>
<item name="android:gravity">center</item>
</style>
</resources>

View file

@ -12,15 +12,4 @@
<item name="direction">rightToLeft</item>
</style>
<style name="Widget.Vector.TextView.Caption.Toast">
<item name="android:paddingTop">8dp</item>
<item name="android:paddingBottom">8dp</item>
<item name="android:paddingStart">12dp</item>
<item name="android:paddingEnd">12dp</item>
<item name="android:background">@drawable/bg_round_corner_8dp</item>
<item name="android:backgroundTint">?vctr_voice_message_toast_background</item>
<item name="android:textColor">@color/palette_white</item>
<item name="android:gravity">center</item>
</style>
</resources>

View file

@ -140,8 +140,7 @@
<!-- Keywords -->
<item name="vctr_keyword_style">@style/Widget.Vector.Keyword</item>
<!-- Voice Message -->
<item name="vctr_voice_message_toast_background">@color/vctr_voice_message_toast_background_dark</item>
<item name="vctr_toast_background">@color/vctr_toast_background_dark</item>
</style>
<style name="Theme.Vector.Dark" parent="Base.Theme.Vector.Dark" />

View file

@ -143,8 +143,7 @@
<!-- Keywords -->
<item name="vctr_keyword_style">@style/Widget.Vector.Keyword</item>
<!-- Voice Message -->
<item name="vctr_voice_message_toast_background">@color/vctr_voice_message_toast_background_light</item>
<item name="vctr_toast_background">@color/vctr_toast_background_light</item>
</style>
<style name="Theme.Vector.Light" parent="Base.Theme.Vector.Light" />

View file

@ -44,6 +44,7 @@ android {
}
testOptions {
// Comment to run on Android 12
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
@ -106,8 +107,9 @@ dependencies {
implementation libs.androidx.appCompat
implementation libs.androidx.core
implementation libs.androidx.lifecycleExtensions
implementation libs.androidx.lifecycleJava8
// Lifecycle
implementation libs.androidx.lifecycleCommon
implementation libs.androidx.lifecycleProcess
// Network
implementation libs.squareup.retrofit
@ -156,10 +158,10 @@ dependencies {
implementation libs.apache.commonsImaging
// Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.36'
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.37'
testImplementation libs.tests.junit
testImplementation 'org.robolectric:robolectric:4.6.1'
testImplementation 'org.robolectric:robolectric:4.7'
//testImplementation 'org.robolectric:shadows-support-v4:3.0'
// Note: version sticks to 1.9.2 due to https://github.com/mockk/mockk/issues/281
testImplementation libs.mockk.mockk

View file

@ -0,0 +1,26 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.pushrules
import org.matrix.android.sdk.api.pushrules.rest.PushRule
import org.matrix.android.sdk.api.session.events.model.Event
data class PushEvents(
val matchedEvents: List<Pair<Event, PushRule>>,
val roomsJoined: Collection<String>,
val roomsLeft: Collection<String>,
val redactedEventIds: List<String>
)

View file

@ -51,11 +51,7 @@ interface PushRuleService {
// fun fulfilledBingRule(event: Event, rules: List<PushRule>): PushRule?
interface PushRuleListener {
fun onMatchRule(event: Event, actions: List<Action>)
fun onRoomJoined(roomId: String)
fun onRoomLeft(roomId: String)
fun onEventRedacted(redactedEventId: String)
fun batchFinish()
fun onEvents(pushEvents: PushEvents)
}
fun getKeywords(): LiveData<Set<String>>

View file

@ -38,14 +38,22 @@ data class MXKey(
/**
* signature user Id to [deviceid][signature]
*/
private val signatures: Map<String, Map<String, String>>
private val signatures: Map<String, Map<String, String>>,
/**
* We have to store the original json because it can contain other fields
* that we don't support yet but they would be needed to check signatures
*/
private val rawMap: JsonDict
) {
/**
* @return the signed data map
*/
fun signalableJSONDictionary(): Map<String, Any> {
return mapOf("key" to value)
return rawMap.filter {
it.key != "signatures" && it.key != "unsigned"
}
}
/**
@ -82,6 +90,7 @@ data class MXKey(
* <pre>
* "signed_curve25519:AAAAFw": {
* "key": "IjwIcskng7YjYcn0tS8TUOT2OHHtBSfMpcfIczCgXj4",
* "fallback" : true|false
* "signatures": {
* "@userId:matrix.org": {
* "ed25519:GMJRREOASV": "EUjp6pXzK9u3SDFR\/qLbzpOi3bEREeI6qMnKzXu992HsfuDDZftfJfiUXv9b\/Hqq1og4qM\/vCQJGTHAWMmgkCg"
@ -107,7 +116,8 @@ data class MXKey(
type = components[0],
keyId = components[1],
value = params["key"] as String,
signatures = params["signatures"] as Map<String, Map<String, String>>
signatures = params["signatures"] as Map<String, Map<String, String>>,
rawMap = params
)
}
}

View file

@ -19,6 +19,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.pushrules.Action
import org.matrix.android.sdk.api.pushrules.PushEvents
import org.matrix.android.sdk.api.pushrules.PushRuleService
import org.matrix.android.sdk.api.pushrules.RuleKind
import org.matrix.android.sdk.api.pushrules.RuleScope
@ -142,79 +143,6 @@ internal class DefaultPushRuleService @Inject constructor(
return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty()
}
// fun processEvents(events: List<Event>) {
// var hasDoneSomething = false
// events.forEach { event ->
// fulfilledBingRule(event)?.let {
// hasDoneSomething = true
// dispatchBing(event, it)
// }
// }
// if (hasDoneSomething)
// dispatchFinish()
// }
fun dispatchBing(event: Event, rule: PushRule) {
synchronized(listeners) {
val actionsList = rule.getActions()
listeners.forEach {
try {
it.onMatchRule(event, actionsList)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching bing")
}
}
}
}
fun dispatchRoomJoined(roomId: String) {
synchronized(listeners) {
listeners.forEach {
try {
it.onRoomJoined(roomId)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching room joined")
}
}
}
}
fun dispatchRoomLeft(roomId: String) {
synchronized(listeners) {
listeners.forEach {
try {
it.onRoomLeft(roomId)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching room left")
}
}
}
}
fun dispatchRedactedEventId(redactedEventId: String) {
synchronized(listeners) {
listeners.forEach {
try {
it.onEventRedacted(redactedEventId)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching redacted event")
}
}
}
}
fun dispatchFinish() {
synchronized(listeners) {
listeners.forEach {
try {
it.batchFinish()
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching finish")
}
}
}
}
override fun getKeywords(): LiveData<Set<String>> {
// Keywords are all content rules that don't start with '.'
val liveData = monarchy.findAllMappedWithChanges(
@ -229,4 +157,16 @@ internal class DefaultPushRuleService @Inject constructor(
results.firstOrNull().orEmpty().toSet()
}
}
fun dispatchEvents(pushEvents: PushEvents) {
synchronized(listeners) {
listeners.forEach {
try {
it.onEvents(pushEvents)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching push events")
}
}
}
}
}

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.session.notification
import org.matrix.android.sdk.api.pushrules.PushEvents
import org.matrix.android.sdk.api.pushrules.rest.PushRule
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isInvitation
@ -39,14 +40,6 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
) : ProcessEventForPushTask {
override suspend fun execute(params: ProcessEventForPushTask.Params) {
// Handle left rooms
params.syncResponse.leave.keys.forEach {
defaultPushRuleService.dispatchRoomLeft(it)
}
// Handle joined rooms
params.syncResponse.join.keys.forEach {
defaultPushRuleService.dispatchRoomJoined(it)
}
val newJoinEvents = params.syncResponse.join
.mapNotNull { (key, value) ->
value.timeline?.events?.mapNotNull {
@ -74,10 +67,10 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
}
Timber.v("[PushRules] Found ${allEvents.size} out of ${(newJoinEvents + inviteEvents).size}" +
" to check for push rules with ${params.rules.size} rules")
allEvents.forEach { event ->
val matchedEvents = allEvents.mapNotNull { event ->
pushRuleFinder.fulfilledBingRule(event, params.rules)?.let {
Timber.v("[PushRules] Rule $it match for event ${event.eventId}")
defaultPushRuleService.dispatchBing(event, it)
event to it
}
}
@ -91,10 +84,13 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
Timber.v("[PushRules] Found ${allRedactedEvents.size} redacted events")
allRedactedEvents.forEach { redactedEventId ->
defaultPushRuleService.dispatchRedactedEventId(redactedEventId)
}
defaultPushRuleService.dispatchFinish()
defaultPushRuleService.dispatchEvents(
PushEvents(
matchedEvents = matchedEvents,
roomsJoined = params.syncResponse.join.keys,
roomsLeft = params.syncResponse.leave.keys,
redactedEventIds = allRedactedEvents
)
)
}
}

View file

@ -16,9 +16,8 @@
package org.matrix.android.sdk.internal.util
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.matrix.android.sdk.internal.di.MatrixScope
import timber.log.Timber
import javax.inject.Inject
@ -27,13 +26,12 @@ import javax.inject.Inject
* To be attached to ProcessLifecycleOwner lifecycle
*/
@MatrixScope
internal class BackgroundDetectionObserver @Inject constructor() : LifecycleObserver {
internal class BackgroundDetectionObserver @Inject constructor() : DefaultLifecycleObserver {
var isInBackground: Boolean = true
private set
private
val listeners = LinkedHashSet<Listener>()
private val listeners = LinkedHashSet<Listener>()
fun register(listener: Listener) {
listeners.add(listener)
@ -43,15 +41,13 @@ internal class BackgroundDetectionObserver @Inject constructor() : LifecycleObse
listeners.remove(listener)
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onMoveToForeground() {
override fun onStart(owner: LifecycleOwner) {
Timber.v("App returning to foreground…")
isInBackground = false
listeners.forEach { it.onMoveToForeground() }
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun onMoveToBackground() {
override fun onStop(owner: LifecycleOwner) {
Timber.v("App going to background…")
isInBackground = true
listeners.forEach { it.onMoveToBackground() }

View file

@ -21,6 +21,7 @@ import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
import im.vector.lib.multipicker.entity.MultiPickerContactType
import im.vector.lib.multipicker.utils.getColumnIndexOrNull
/**
* Contact Picker implementation
@ -49,9 +50,9 @@ class ContactPicker : Picker<MultiPickerContactType>() {
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndex(ContactsContract.Contacts._ID)
val nameColumn = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
val photoUriColumn = cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
val idColumn = cursor.getColumnIndexOrNull(ContactsContract.Contacts._ID) ?: return@use
val nameColumn = cursor.getColumnIndexOrNull(ContactsContract.Contacts.DISPLAY_NAME) ?: return@use
val photoUriColumn = cursor.getColumnIndexOrNull(ContactsContract.Contacts.PHOTO_URI) ?: return@use
val contactId = cursor.getInt(idColumn)
var name = cursor.getString(nameColumn)
@ -72,10 +73,13 @@ class ContactPicker : Picker<MultiPickerContactType>() {
selection,
selectionArgs,
null
)?.use { cursor ->
while (cursor.moveToNext()) {
val mimeType = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.MIMETYPE))
val contactData = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.DATA1))
)?.use inner@{ innerCursor ->
val mimeTypeColumnIndex = innerCursor.getColumnIndexOrNull(ContactsContract.Data.MIMETYPE) ?: return@inner
val data1ColumnIndex = innerCursor.getColumnIndexOrNull(ContactsContract.Data.DATA1) ?: return@inner
while (innerCursor.moveToNext()) {
val mimeType = innerCursor.getString(mimeTypeColumnIndex)
val contactData = innerCursor.getString(data1ColumnIndex)
if (mimeType == ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) {
name = contactData
@ -115,7 +119,10 @@ class ContactPicker : Picker<MultiPickerContactType>() {
selectionArgs,
null
)?.use { cursor ->
return if (cursor.moveToFirst()) cursor.getInt(cursor.getColumnIndex(ContactsContract.RawContacts._ID)) else null
return if (cursor.moveToFirst()) {
cursor.getColumnIndexOrNull(ContactsContract.RawContacts._ID)
?.let { cursor.getInt(it) }
} else null
}
}

View file

@ -21,6 +21,7 @@ import android.content.Intent
import android.provider.OpenableColumns
import im.vector.lib.multipicker.entity.MultiPickerBaseType
import im.vector.lib.multipicker.entity.MultiPickerFileType
import im.vector.lib.multipicker.utils.getColumnIndexOrNull
import im.vector.lib.multipicker.utils.isMimeTypeAudio
import im.vector.lib.multipicker.utils.isMimeTypeImage
import im.vector.lib.multipicker.utils.isMimeTypeVideo
@ -49,8 +50,8 @@ class FilePicker : Picker<MultiPickerBaseType>() {
// Other files
context.contentResolver.query(selectedUri, null, null, null, null)
?.use { cursor ->
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
val nameColumn = cursor.getColumnIndexOrNull(OpenableColumns.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndexOrNull(OpenableColumns.SIZE) ?: return@use null
if (cursor.moveToFirst()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)

View file

@ -37,8 +37,8 @@ internal fun Uri.toMultiPickerImageType(context: Context): MultiPickerImageType?
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
val nameColumn = cursor.getColumnIndexOrNull(MediaStore.Images.Media.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndexOrNull(MediaStore.Images.Media.SIZE) ?: return@use null
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
@ -75,8 +75,8 @@ internal fun Uri.toMultiPickerVideoType(context: Context): MultiPickerVideoType?
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE)
val nameColumn = cursor.getColumnIndexOrNull(MediaStore.Video.Media.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndexOrNull(MediaStore.Video.Media.SIZE) ?: return@use null
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
@ -124,8 +124,8 @@ fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType? {
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)
val nameColumn = cursor.getColumnIndexOrNull(MediaStore.Audio.Media.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndexOrNull(MediaStore.Audio.Media.SIZE) ?: return@use null
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.lib.multipicker.utils
import android.database.Cursor
fun Cursor.getColumnIndexOrNull(column: String): Int? {
return getColumnIndex(column).takeIf { it != -1 }
}

View file

@ -4191,7 +4191,8 @@
"call",
"hand",
"hands",
"gesture"
"gesture",
"shaka"
]
},
"backhand-index-pointing-left": {

View file

@ -17,7 +17,7 @@ PARAM_KEYSTORE_PATH=$1
PARAM_APK=$2
# Other params
BUILD_TOOLS_VERSION="30.0.3"
BUILD_TOOLS_VERSION="31.0.0-rc5"
MIN_SDK_VERSION=21
echo "Signing APK with build-tools version ${BUILD_TOOLS_VERSION} for min SDK version ${MIN_SDK_VERSION}..."

View file

@ -23,7 +23,7 @@ PARAM_KS_PASS=$3
PARAM_KEY_PASS=$4
# Other params
BUILD_TOOLS_VERSION="30.0.3"
BUILD_TOOLS_VERSION="31.0.0-rc5"
MIN_SDK_VERSION=21
echo "Signing APK with build-tools version ${BUILD_TOOLS_VERSION} for min SDK version ${MIN_SDK_VERSION}..."

View file

@ -210,6 +210,7 @@ android {
// This property does not affect tests that you run using Android Studio.
animationsDisabled = true
// Comment to run on Android 12
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
@ -356,8 +357,10 @@ dependencies {
implementation libs.squareup.moshi
kapt libs.squareup.moshiKotlin
implementation libs.androidx.lifecycleExtensions
// Lifecycle
implementation libs.androidx.lifecycleLivedata
implementation libs.androidx.lifecycleProcess
implementation libs.androidx.datastore
implementation libs.androidx.datastorepreferences
@ -370,7 +373,7 @@ dependencies {
implementation 'com.facebook.stetho:stetho:1.6.0'
// Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.36'
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.37'
// FlowBinding
implementation libs.github.flowBinding
@ -411,7 +414,7 @@ dependencies {
implementation 'com.github.Armen101:AudioRecordView:1.0.5'
// Custom Tab
implementation 'androidx.browser:browser:1.3.0'
implementation 'androidx.browser:browser:1.4.0'
// Passphrase strength helper
implementation 'com.nulab-inc:zxcvbn:1.5.2'

View file

@ -19,6 +19,7 @@ package im.vector.app
import android.app.Activity
import android.view.View
import androidx.annotation.StringRes
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer
import androidx.test.espresso.Espresso
import androidx.test.espresso.IdlingRegistry
@ -35,6 +36,11 @@ import androidx.test.runner.lifecycle.ActivityLifecycleCallback
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import com.adevinta.android.barista.interaction.BaristaClickInteractions
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.espresso.tools.waitUntilViewVisible
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.StringDescription
@ -52,6 +58,18 @@ object EspressoHelper {
}
return currentActivity
}
inline fun <reified T : VectorBaseBottomSheetDialogFragment<*>> getBottomSheetDialog(): BottomSheetDialogFragment? {
return (getCurrentActivity() as? FragmentActivity)
?.supportFragmentManager
?.fragments
?.filterIsInstance<T>()
?.firstOrNull()
}
}
fun getString(@StringRes id: Int): String {
return EspressoHelper.getCurrentActivity()!!.resources.getString(id)
}
fun waitForView(viewMatcher: Matcher<View>, timeout: Long = 10_000, waitForDisplayed: Boolean = true): ViewAction {
@ -216,3 +234,46 @@ fun clickOnAndGoBack(@StringRes name: Int, block: () -> Unit) {
block()
Espresso.pressBack()
}
inline fun <reified T : VectorBaseBottomSheetDialogFragment<*>> interactWithSheet(contentMatcher: Matcher<View>, noinline block: () -> Unit = {}) {
waitUntilViewVisible(contentMatcher)
val behaviour = (EspressoHelper.getBottomSheetDialog<T>()!!.dialog as BottomSheetDialog).behavior
withIdlingResource(BottomSheetResource(behaviour, BottomSheetBehavior.STATE_EXPANDED), block)
withIdlingResource(BottomSheetResource(behaviour, BottomSheetBehavior.STATE_HIDDEN)) {}
}
class BottomSheetResource(
private val bottomSheetBehavior: BottomSheetBehavior<*>,
@BottomSheetBehavior.State private val wantedState: Int
) : IdlingResource, BottomSheetBehavior.BottomSheetCallback() {
private var isIdle: Boolean = false
private var resourceCallback: IdlingResource.ResourceCallback? = null
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
override fun onStateChanged(bottomSheet: View, newState: Int) {
val wasIdle = isIdle
isIdle = newState == BottomSheetBehavior.STATE_EXPANDED
if (!wasIdle && isIdle) {
bottomSheetBehavior.removeBottomSheetCallback(this)
resourceCallback?.onTransitionToIdle()
}
}
override fun getName() = "BottomSheet awaiting state: $wantedState"
override fun isIdleNow() = isIdle
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
resourceCallback = callback
val state = bottomSheetBehavior.state
isIdle = state == wantedState
if (isIdle) {
resourceCallback!!.onTransitionToIdle()
} else {
bottomSheetBehavior.addBottomSheetCallback(this)
}
}
}

View file

@ -0,0 +1,119 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.espresso.tools
import android.content.ContentResolver
import android.content.ContentValues
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val SCREENSHOT_FOLDER_LOCATION = "${Environment.DIRECTORY_PICTURES}/failure_screenshots"
private val deviceLanguage = Locale.getDefault().language
class ScreenshotFailureRule : TestWatcher() {
override fun failed(e: Throwable?, description: Description) {
val screenShotName = "$deviceLanguage-${description.methodName}-${SimpleDateFormat("EEE-MMMM-dd-HH:mm:ss").format(Date())}"
val bitmap = getInstrumentation().uiAutomation.takeScreenshot()
storeFailureScreenshot(bitmap, screenShotName)
}
}
/**
* Stores screenshots in sdcard/Pictures/failure_screenshots
*/
private fun storeFailureScreenshot(bitmap: Bitmap, screenshotName: String) {
val contentResolver = getInstrumentation().targetContext.applicationContext.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
useMediaStoreScreenshotStorage(
contentValues,
contentResolver,
screenshotName,
SCREENSHOT_FOLDER_LOCATION,
bitmap
)
} else {
usePublicExternalScreenshotStorage(
contentValues,
contentResolver,
screenshotName,
SCREENSHOT_FOLDER_LOCATION,
bitmap
)
}
}
private fun useMediaStoreScreenshotStorage(
contentValues: ContentValues,
contentResolver: ContentResolver,
screenshotName: String,
screenshotLocation: String,
bitmap: Bitmap
) {
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "$screenshotName.jpeg")
contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, screenshotLocation)
val uri: Uri? = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
contentResolver.openOutputStream(uri)?.let { saveScreenshotToStream(bitmap, it) }
contentResolver.update(uri, contentValues, null, null)
}
}
@Suppress("DEPRECATION")
private fun usePublicExternalScreenshotStorage(
contentValues: ContentValues,
contentResolver: ContentResolver,
screenshotName: String,
screenshotLocation: String,
bitmap: Bitmap
) {
val directory = File(Environment.getExternalStoragePublicDirectory(screenshotLocation).toString())
if (!directory.exists()) {
directory.mkdirs()
}
val file = File(directory, "$screenshotName.jpeg")
saveScreenshotToStream(bitmap, FileOutputStream(file))
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
}
private fun saveScreenshotToStream(bitmap: Bitmap, outputStream: OutputStream) {
outputStream.use {
try {
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, it)
} catch (e: IOException) {
Timber.e("Screenshot was not stored at this time")
}
}
}

View file

@ -19,10 +19,15 @@ package im.vector.app.ui
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import im.vector.app.R
import im.vector.app.espresso.tools.ScreenshotFailureRule
import im.vector.app.features.MainActivity
import im.vector.app.getString
import im.vector.app.ui.robot.ElementRobot
import im.vector.app.ui.robot.withDeveloperMode
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import java.util.UUID
@ -34,7 +39,9 @@ import java.util.UUID
class UiAllScreensSanityTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
val testRule = RuleChain
.outerRule(ActivityScenarioRule(MainActivity::class.java))
.around(ScreenshotFailureRule())
private val elementRobot = ElementRobot()
@ -69,13 +76,30 @@ class UiAllScreensSanityTest {
createNewRoom {
crawl()
createRoom {
postMessage("Hello world!")
val message = "Hello world!"
postMessage(message)
crawl()
crawlMessage(message)
openSettings { crawl() }
}
}
}
elementRobot.withDeveloperMode {
settings {
advancedSettings { crawlDeveloperOptions() }
}
roomList {
openRoom(getString(R.string.room_displayname_empty_room)) {
val message = "Test view source"
postMessage(message)
openMessageMenu(message) {
viewSource()
}
}
}
}
elementRobot.roomList {
verifyCreatedRoom()
}

View file

@ -141,3 +141,9 @@ class ElementRobot {
}
private fun Boolean.toWarningType() = if (this) "shown" else "skipped"
fun ElementRobot.withDeveloperMode(block: ElementRobot.() -> Unit) {
settings { toggleDeveloperMode() }
block()
settings { toggleDeveloperMode() }
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.ui.robot
import androidx.test.espresso.Espresso.pressBack
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import com.adevinta.android.barista.interaction.BaristaListInteractions.clickListItem
import im.vector.app.R
import java.lang.Thread.sleep
class MessageMenuRobot(
var autoClosed: Boolean = false
) {
fun viewSource() {
clickOn(R.string.view_source)
// wait for library
sleep(1000)
pressBack()
autoClosed = true
}
fun editHistory() {
clickOn(R.string.message_view_edit_history)
pressBack()
autoClosed = true
}
fun addQuickReaction(quickReaction: String) {
clickOn(quickReaction)
autoClosed = true
}
fun addReactionFromEmojiPicker() {
clickOn(R.string.message_add_reaction)
// Wait for emoji to load, it's async now
sleep(2000)
clickListItem(R.id.emojiRecyclerView, 4)
autoClosed = true
}
fun edit() {
clickOn(R.string.edit)
autoClosed = true
}
}

View file

@ -17,21 +17,24 @@
package im.vector.app.ui.robot
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.interaction.BaristaClickInteractions
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import com.adevinta.android.barista.interaction.BaristaClickInteractions.longClickOn
import com.adevinta.android.barista.interaction.BaristaEditTextInteractions.writeTo
import com.adevinta.android.barista.interaction.BaristaListInteractions.clickListItem
import com.adevinta.android.barista.interaction.BaristaMenuClickInteractions.clickMenu
import com.adevinta.android.barista.interaction.BaristaMenuClickInteractions.openMenu
import im.vector.app.R
import im.vector.app.espresso.tools.waitUntilViewVisible
import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.app.features.reactions.data.EmojiDataSource
import im.vector.app.interactWithSheet
import im.vector.app.waitForView
import java.lang.Thread.sleep
@ -39,7 +42,9 @@ class RoomDetailRobot {
fun postMessage(content: String) {
writeTo(R.id.composerEditText, content)
waitUntilViewVisible(withId(R.id.sendButton))
clickOn(R.id.sendButton)
waitUntilViewVisible(withText(content))
}
fun crawl() {
@ -55,61 +60,54 @@ class RoomDetailRobot {
pressBack()
clickMenu(R.id.search)
pressBack()
// Long click on the message
longClickOnMessageTest()
}
private fun longClickOnMessageTest() {
fun crawlMessage(message: String) {
// Test quick reaction
longClickOnMessage()
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
// Add quick reaction
clickOn("\uD83D\uDC4D") // 👍
waitUntilViewVisible(withId(R.id.composerEditText))
val quickReaction = EmojiDataSource.quickEmojis[0] // 👍
openMessageMenu(message) {
addQuickReaction(quickReaction)
}
// Open reactions
longClickOn("\uD83D\uDC4D") // 👍
longClickOn(quickReaction)
// wait for bottom sheet
pressBack()
// Test add reaction
longClickOnMessage()
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
clickOn(R.string.message_add_reaction)
// Filter
// TODO clickMenu(R.id.search)
// Wait for emoji to load, it's async now
sleep(2000)
clickListItem(R.id.emojiRecyclerView, 4)
waitUntilViewVisible(withId(R.id.composerEditText))
openMessageMenu(message) {
addReactionFromEmojiPicker()
}
// Test Edit mode
longClickOnMessage()
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
clickOn(R.string.edit)
waitUntilViewVisible(withId(R.id.composerEditText))
openMessageMenu(message) {
edit()
}
// TODO Cancel action
writeTo(R.id.composerEditText, "Hello universe!")
// Wait a bit for the keyboard layout to update
sleep(30)
waitUntilViewVisible(withId(R.id.sendButton))
clickOn(R.id.sendButton)
// Wait for the UI to update
sleep(1000)
waitUntilViewVisible(withText("Hello universe! (edited)"))
// Open edit history
longClickOnMessage("Hello universe! (edited)")
waitUntilViewVisible(withId(R.id.bottomSheetRecyclerView))
clickOn(R.string.message_view_edit_history)
pressBack()
openMessageMenu("Hello universe! (edited)") {
editHistory()
}
}
private fun longClickOnMessage(text: String = "Hello world!") {
Espresso.onView(withId(R.id.timelineRecyclerView))
fun openMessageMenu(message: String, block: MessageMenuRobot.() -> Unit) {
onView(withId(R.id.timelineRecyclerView))
.perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
ViewMatchers.hasDescendant(ViewMatchers.withText(text)),
ViewMatchers.hasDescendant(ViewMatchers.withText(message)),
ViewActions.longClick()
)
)
interactWithSheet<MessageActionsBottomSheet>(contentMatcher = withId(R.id.bottomSheetRecyclerView)) {
val messageMenuRobot = MessageMenuRobot()
block(messageMenuRobot)
if (!messageMenuRobot.autoClosed) {
pressBack()
}
}
}
fun openSettings(block: RoomSettingsRobot.() -> Unit) {

View file

@ -17,38 +17,46 @@
package im.vector.app.ui.robot
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions
import com.adevinta.android.barista.interaction.BaristaClickInteractions
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import im.vector.app.R
import im.vector.app.espresso.tools.waitUntilActivityVisible
import im.vector.app.features.roomdirectory.RoomDirectoryActivity
class RoomListRobot {
fun openRoom(roomName: String, block: RoomDetailRobot.() -> Unit) {
clickOn(roomName)
block(RoomDetailRobot())
pressBack()
}
fun verifyCreatedRoom() {
Espresso.onView(ViewMatchers.withId(R.id.roomListView))
onView(ViewMatchers.withId(R.id.roomListView))
.perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.room_displayname_empty_room)),
ViewMatchers.hasDescendant(withText(R.string.room_displayname_empty_room)),
ViewActions.longClick()
)
)
Espresso.pressBack()
pressBack()
}
fun newRoom(block: NewRoomRobot.() -> Unit) {
BaristaClickInteractions.clickOn(R.id.createGroupRoomButton)
clickOn(R.id.createGroupRoomButton)
waitUntilActivityVisible<RoomDirectoryActivity> {
BaristaVisibilityAssertions.assertDisplayed(R.id.publicRoomsList)
}
val newRoomRobot = NewRoomRobot()
block(newRoomRobot)
if (!newRoomRobot.createdRoom) {
Espresso.pressBack()
pressBack()
}
}
}

View file

@ -17,8 +17,11 @@
package im.vector.app.ui.robot.settings
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import im.vector.app.R
import im.vector.app.espresso.tools.clickOnPreference
import im.vector.app.espresso.tools.waitUntilViewVisible
class SettingsAdvancedRobot {
@ -28,20 +31,19 @@ class SettingsAdvancedRobot {
clickOnPreference(R.string.settings_push_rules)
pressBack()
}
/* TODO P2 test developer screens
// Enable developer mode
clickOnSwitchPreference("SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY")
fun toggleDeveloperMode() {
clickOn(R.string.settings_developer_mode_summary)
}
fun crawlDeveloperOptions() {
clickOnPreference(R.string.settings_account_data)
waitUntilViewVisible(withText("m.push_rules"))
clickOn("m.push_rules")
pressBack()
pressBack()
clickOnPreference(R.string.settings_key_requests)
pressBack()
// Disable developer mode
clickOnSwitchPreference("SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY")
*/
}
}

View file

@ -21,6 +21,12 @@ import im.vector.app.clickOnAndGoBack
class SettingsRobot {
fun toggleDeveloperMode() {
advancedSettings {
toggleDeveloperMode()
}
}
fun general(block: SettingsGeneralRobot.() -> Unit) {
clickOnAndGoBack(R.string.settings_general_title) { block(SettingsGeneralRobot()) }
}
@ -50,7 +56,9 @@ class SettingsRobot {
}
fun advancedSettings(block: SettingsAdvancedRobot.() -> Unit) {
clickOnAndGoBack(R.string.settings_advanced_settings) { block(SettingsAdvancedRobot()) }
clickOnAndGoBack(R.string.settings_advanced_settings) {
block(SettingsAdvancedRobot())
}
}
fun helpAndAbout(block: SettingsHelpRobot.() -> Unit) {

View file

@ -14,7 +14,9 @@
<application>
<receiver android:name=".fdroid.receiver.OnApplicationUpgradeOrRebootReceiver">
<receiver
android:name=".fdroid.receiver.OnApplicationUpgradeOrRebootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />

View file

@ -25,6 +25,7 @@ import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import im.vector.app.core.extensions.singletonEntryPoint
import im.vector.app.core.platform.PendingIntentCompat
import im.vector.app.core.services.VectorSyncService
import org.matrix.android.sdk.internal.session.sync.job.SyncService
import timber.log.Timber
@ -67,7 +68,12 @@ class AlarmSyncBroadcastReceiver : BroadcastReceiver() {
putExtra(SyncService.EXTRA_SESSION_ID, sessionId)
putExtra(SyncService.EXTRA_PERIODIC, true)
}
val pIntent = PendingIntent.getBroadcast(context, REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val pIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
val firstMillis = System.currentTimeMillis() + delayInSeconds * 1000L
val alarmMgr = context.getSystemService<AlarmManager>()!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@ -80,7 +86,12 @@ class AlarmSyncBroadcastReceiver : BroadcastReceiver() {
fun cancelAlarm(context: Context) {
Timber.v("## Sync: Cancel alarm for background sync")
val intent = Intent(context, AlarmSyncBroadcastReceiver::class.java)
val pIntent = PendingIntent.getBroadcast(context, REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val pIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
val alarmMgr = context.getSystemService<AlarmManager>()!!
alarmMgr.cancel(pIntent)

View file

@ -9,7 +9,9 @@
android:name="firebase_analytics_collection_deactivated"
android:value="true" />
<service android:name=".gplay.push.fcm.VectorFirebaseMessagingService">
<service
android:name=".gplay.push.fcm.VectorFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>

View file

@ -201,8 +201,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
resolvedEvent
?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
?.let {
notificationDrawerManager.onNotifiableEventReceived(it)
notificationDrawerManager.refreshNotificationDrawer()
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(resolvedEvent) }
}
}
}

View file

@ -4,7 +4,10 @@
package="im.vector.app">
<!-- Needed for VOIP call to detect and switch to headset-->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -418,6 +421,22 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/sdk_provider_paths" />
</provider>
<!-- Temporary fix for Android 12. android:exported has to be explicitly set when targeting Android 12
Do it for services coming from dependencies - BEGIN -->
<service
android:name="org.jitsi.meet.sdk.ConnectionService"
android:exported="false"
tools:node="merge" />
<service
android:name="org.jitsi.meet.sdk.JitsiMeetOngoingConferenceService"
android:exported="false"
tools:node="merge" />
<service
android:name="androidx.sharetarget.ChooserTargetServiceCompat"
android:exported="false"
tools:node="merge" />
<!-- Temporary fix for Android 12 change - END -->
</application>
</manifest>

View file

@ -16,9 +16,8 @@
package im.vector.app
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import arrow.core.Option
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.utils.BehaviorDataSource
@ -57,7 +56,7 @@ class AppStateHandler @Inject constructor(
private val sessionDataSource: ActiveSessionDataSource,
private val uiStateRepository: UiStateRepository,
private val activeSessionHolder: ActiveSessionHolder
) : LifecycleObserver {
) : DefaultLifecycleObserver {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val selectedSpaceDataSource = BehaviorDataSource<Option<RoomGroupingMethod>>(Option.empty())
@ -133,13 +132,11 @@ class AppStateHandler @Inject constructor(
return (selectedSpaceDataSource.currentValue?.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun entersForeground() {
override fun onResume(owner: LifecycleOwner) {
observeActiveSession()
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun entersBackground() {
override fun onPause(owner: LifecycleOwner) {
coroutineScope.coroutineContext.cancelChildren()
val session = activeSessionHolder.getSafeActiveSession() ?: return
when (val currentMethod = selectedSpaceDataSource.currentValue?.orNull() ?: RoomGroupingMethod.BySpace(null)) {

View file

@ -27,9 +27,8 @@ import android.os.HandlerThread
import android.os.StrictMode
import androidx.core.provider.FontRequest
import androidx.core.provider.FontsContractCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.multidex.MultiDex
import com.airbnb.epoxy.EpoxyAsyncUtil
@ -166,9 +165,8 @@ class VectorApplication :
ProcessLifecycleOwner.get().lifecycle.addObserver(startSyncOnFirstStart)
ProcessLifecycleOwner.get().lifecycle.addObserver(object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun entersForeground() {
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
Timber.i("App entered foreground")
FcmHelper.onEnterForeground(appContext, activeSessionHolder)
activeSessionHolder.getSafeActiveSession()?.also {
@ -176,8 +174,7 @@ class VectorApplication :
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun entersBackground() {
override fun onPause(owner: LifecycleOwner) {
Timber.i("App entered background") // call persistInfo
notificationDrawerManager.persistInfo()
FcmHelper.onEnterBackground(appContext, vectorPreferences, activeSessionHolder)
@ -198,9 +195,8 @@ class VectorApplication :
EmojiManager.install(GoogleEmojiProvider())
}
private val startSyncOnFirstStart = object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onStart() {
private val startSyncOnFirstStart = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
Timber.i("App process started")
authenticationService.getLastAuthenticatedSession()?.startSyncing(appContext)
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)

View file

@ -17,10 +17,10 @@
package im.vector.app.core.contacts
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import androidx.annotation.WorkerThread
import im.vector.lib.multipicker.utils.getColumnIndexOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.system.measureTimeMillis
@ -57,16 +57,20 @@ class ContactsDataSource @Inject constructor(
)
?.use { cursor ->
if (cursor.count > 0) {
val idColumnIndex = cursor.getColumnIndexOrNull(ContactsContract.Contacts._ID) ?: return@use
val displayNameColumnIndex = cursor.getColumnIndexOrNull(ContactsContract.Contacts.DISPLAY_NAME) ?: return@use
val photoUriColumnIndex = cursor.getColumnIndexOrNull(ContactsContract.Data.PHOTO_URI)
while (cursor.moveToNext()) {
val id = cursor.getLong(ContactsContract.Contacts._ID) ?: continue
val displayName = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME) ?: continue
val id = cursor.getLong(idColumnIndex)
val displayName = cursor.getString(displayNameColumnIndex)
val mappedContactBuilder = MappedContactBuilder(
id = id,
displayName = displayName
)
cursor.getString(ContactsContract.Data.PHOTO_URI)
photoUriColumnIndex
?.let { cursor.getString(it) }
?.let { Uri.parse(it) }
?.let { mappedContactBuilder.photoURI = it }
@ -85,12 +89,15 @@ class ContactsDataSource @Inject constructor(
null,
null,
null)
?.use { innerCursor ->
while (innerCursor.moveToNext()) {
val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
?.let { map[it] }
?.use { cursor ->
val idColumnIndex = cursor.getColumnIndexOrNull(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) ?: return@use
val phoneNumberColumnIndex = cursor.getColumnIndexOrNull(ContactsContract.CommonDataKinds.Phone.NUMBER) ?: return@use
while (cursor.moveToNext()) {
val mappedContactBuilder = cursor.getLong(idColumnIndex)
.let { map[it] }
?: continue
innerCursor.getString(ContactsContract.CommonDataKinds.Phone.NUMBER)
cursor.getString(phoneNumberColumnIndex)
?.let {
mappedContactBuilder.msisdns.add(
MappedMsisdn(
@ -114,14 +121,17 @@ class ContactsDataSource @Inject constructor(
null,
null,
null)
?.use { innerCursor ->
while (innerCursor.moveToNext()) {
?.use { cursor ->
val idColumnIndex = cursor.getColumnIndexOrNull(ContactsContract.CommonDataKinds.Email.CONTACT_ID) ?: return@use
val emailColumnIndex = cursor.getColumnIndexOrNull(ContactsContract.CommonDataKinds.Email.DATA) ?: return@use
while (cursor.moveToNext()) {
// This would allow you get several email addresses
// if the email addresses were stored in an array
val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Email.CONTACT_ID)
?.let { map[it] }
val mappedContactBuilder = cursor.getLong(idColumnIndex)
.let { map[it] }
?: continue
innerCursor.getString(ContactsContract.CommonDataKinds.Email.DATA)
cursor.getString(emailColumnIndex)
?.let {
mappedContactBuilder.emails.add(
MappedEmail(
@ -140,16 +150,4 @@ class ContactsDataSource @Inject constructor(
.filter { it.emails.isNotEmpty() || it.msisdns.isNotEmpty() }
.map { it.build() }
}
private fun Cursor.getString(column: String): String? {
return getColumnIndex(column)
.takeIf { it != -1 }
?.let { getString(it) }
}
private fun Cursor.getLong(column: String): Long? {
return getColumnIndex(column)
.takeIf { it != -1 }
?.let { getLong(it) }
}
}

View file

@ -66,3 +66,7 @@ fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String {
replaceRange(idx, idx, insert)
}
}
inline fun <reified R> Any?.takeAs(): R? {
return takeIf { it is R } as R?
}

View file

@ -19,13 +19,15 @@ package im.vector.app.core.intent
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import im.vector.lib.multipicker.utils.getColumnIndexOrNull
fun getFilenameFromUri(context: Context?, uri: Uri): String? {
if (context != null && uri.scheme == "content") {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
return it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
context.contentResolver.query(uri, null, null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getColumnIndexOrNull(OpenableColumns.DISPLAY_NAME)
?.let { cursor.getString(it) }
}
}
}

View file

@ -18,10 +18,9 @@ package im.vector.app.core.platform
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
fun <T> LifecycleOwner.lifecycleAwareLazy(initializer: () -> T): Lazy<T> = LifecycleAwareLazy(this, initializer)
@ -30,7 +29,7 @@ private object UninitializedValue
class LifecycleAwareLazy<out T>(
private val owner: LifecycleOwner,
initializer: () -> T
) : Lazy<T>, LifecycleObserver {
) : Lazy<T>, DefaultLifecycleObserver {
private var initializer: (() -> T)? = initializer
@ -47,8 +46,7 @@ class LifecycleAwareLazy<out T>(
return _value as T
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun resetValue() {
override fun onDestroy(owner: LifecycleOwner) {
_value = UninitializedValue
detachFromLifecycle()
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,17 +14,21 @@
* limitations under the License.
*/
package im.vector.app.features.call.telecom
package im.vector.app.core.platform
import android.content.Context
import android.telephony.TelephonyManager
import androidx.core.content.getSystemService
import android.app.PendingIntent
import android.os.Build
object TelecomUtils {
object PendingIntentCompat {
val FLAG_IMMUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
fun isLineBusy(context: Context): Boolean {
val telephonyManager = context.getSystemService<TelephonyManager>()
?: return false
return telephonyManager.callState != TelephonyManager.CALL_STATE_IDLE
val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
}

View file

@ -16,6 +16,7 @@
package im.vector.app.core.platform
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
@ -403,7 +404,12 @@ abstract class VectorBaseActivity<VB : ViewBinding> : AppCompatActivity(), Maver
// New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
window.setDecorFitsSystemWindows(false)
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
@SuppressLint("WrongConstant")
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
}
// New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, im.vector.lib.attachmentviewer.R.color.half_transparent_status_bar)
// New API instead of FLAG_TRANSLUCENT_NAVIGATION

View file

@ -32,6 +32,7 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.platform.PendingIntentCompat
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.settings.BackgroundSyncMode
import org.matrix.android.sdk.internal.session.sync.job.SyncService
@ -199,9 +200,9 @@ private fun Context.rescheduleSyncService(sessionId: String,
startService(intent)
} else {
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(this, 0, intent, 0)
PendingIntent.getForegroundService(this, 0, intent, PendingIntentCompat.FLAG_IMMUTABLE)
} else {
PendingIntent.getService(this, 0, intent, 0)
PendingIntent.getService(this, 0, intent, PendingIntentCompat.FLAG_IMMUTABLE)
}
val firstMillis = System.currentTimeMillis() + syncDelaySeconds * 1000L
val alarmMgr = getSystemService<AlarmManager>()!!

View file

@ -20,9 +20,8 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.facebook.react.bridge.JavaOnlyMap
import org.jitsi.meet.sdk.BroadcastEmitter
@ -52,7 +51,7 @@ class ConferenceEventEmitter(private val context: Context) {
class ConferenceEventObserver(private val context: Context,
private val onBroadcastEvent: (ConferenceEvent) -> Unit) :
LifecycleObserver {
DefaultLifecycleObserver {
// See https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-android-sdk#listening-for-broadcasted-events
private val broadcastReceiver = object : BroadcastReceiver() {
@ -61,8 +60,7 @@ class ConferenceEventObserver(private val context: Context,
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun unregisterForBroadcastMessages() {
override fun onDestroy(owner: LifecycleOwner) {
try {
LocalBroadcastManager.getInstance(context).unregisterReceiver(broadcastReceiver)
} catch (throwable: Throwable) {
@ -70,8 +68,7 @@ class ConferenceEventObserver(private val context: Context,
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun registerForBroadcastMessages() {
override fun onCreate(owner: LifecycleOwner) {
val intentFilter = IntentFilter()
for (type in BroadcastEvent.Type.values()) {
intentFilter.addAction(type.action)

View file

@ -17,9 +17,8 @@
package im.vector.app.features.call.webrtc
import android.content.Context
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import im.vector.app.ActiveSessionDataSource
import im.vector.app.BuildConfig
import im.vector.app.core.services.CallService
@ -70,7 +69,8 @@ private val loggerTag = LoggerTag("WebRtcCallManager", LoggerTag.VOIP)
class WebRtcCallManager @Inject constructor(
private val context: Context,
private val activeSessionDataSource: ActiveSessionDataSource
) : CallListener, LifecycleObserver {
) : CallListener,
DefaultLifecycleObserver {
private val currentSession: Session?
get() = activeSessionDataSource.currentValue?.orNull()
@ -133,13 +133,11 @@ class WebRtcCallManager @Inject constructor(
private var isInBackground: Boolean = true
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun entersForeground() {
override fun onResume(owner: LifecycleOwner) {
isInBackground = false
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun entersBackground() {
override fun onPause(owner: LifecycleOwner) {
isInBackground = true
}

View file

@ -29,13 +29,8 @@ class KeysBackupRestoreFromKeyViewModel @Inject constructor(
private val stringProvider: StringProvider
) : ViewModel() {
var recoveryCode: MutableLiveData<String> = MutableLiveData()
var recoveryCodeErrorText: MutableLiveData<String> = MutableLiveData()
init {
recoveryCode.value = null
recoveryCodeErrorText.value = null
}
var recoveryCode: MutableLiveData<String?> = MutableLiveData(null)
var recoveryCodeErrorText: MutableLiveData<String?> = MutableLiveData(null)
// ========= Actions =========
fun updateCode(newValue: String) {

View file

@ -28,13 +28,8 @@ class KeysBackupRestoreFromPassphraseViewModel @Inject constructor(
private val stringProvider: StringProvider
) : ViewModel() {
var passphrase: MutableLiveData<String> = MutableLiveData()
var passphraseErrorText: MutableLiveData<String> = MutableLiveData()
init {
passphrase.value = null
passphraseErrorText.value = null
}
var passphrase: MutableLiveData<String?> = MutableLiveData(null)
var passphraseErrorText: MutableLiveData<String?> = MutableLiveData(null)
// ========= Actions =========

View file

@ -57,7 +57,7 @@ class KeysBackupRestoreSharedViewModel @Inject constructor(
lateinit var session: Session
var keyVersionResult: MutableLiveData<KeysVersionResult> = MutableLiveData()
var keyVersionResult: MutableLiveData<KeysVersionResult?> = MutableLiveData(null)
var keySourceModel: MutableLiveData<KeySource> = MutableLiveData()
@ -69,17 +69,11 @@ class KeysBackupRestoreSharedViewModel @Inject constructor(
val navigateEvent: LiveData<LiveEvent<String>>
get() = _navigateEvent
var loadingEvent: MutableLiveData<WaitingViewData> = MutableLiveData()
var loadingEvent: MutableLiveData<WaitingViewData?> = MutableLiveData(null)
var importKeyResult: ImportRoomKeysResult? = null
var importRoomKeysFinishWithResult: MutableLiveData<LiveEvent<ImportRoomKeysResult>> = MutableLiveData()
init {
keyVersionResult.value = null
_keyVersionResultError.value = null
loadingEvent.value = null
}
fun initSession(session: Session) {
this.session = session
}

View file

@ -68,23 +68,15 @@ class KeysBackupSetupSharedViewModel @Inject constructor() : ViewModel() {
// Step 3
// Var to ignore events from previous request(s) to generate a recovery key
private var currentRequestId: MutableLiveData<Long> = MutableLiveData()
var recoveryKey: MutableLiveData<String> = MutableLiveData()
var prepareRecoverFailError: MutableLiveData<Throwable> = MutableLiveData()
var recoveryKey: MutableLiveData<String?> = MutableLiveData(null)
var prepareRecoverFailError: MutableLiveData<Throwable?> = MutableLiveData(null)
var megolmBackupCreationInfo: MegolmBackupCreationInfo? = null
var copyHasBeenMade = false
var isCreatingBackupVersion: MutableLiveData<Boolean> = MutableLiveData()
var creatingBackupError: MutableLiveData<Throwable> = MutableLiveData()
var isCreatingBackupVersion: MutableLiveData<Boolean> = MutableLiveData(false)
var creatingBackupError: MutableLiveData<Throwable?> = MutableLiveData(null)
var keysVersion: MutableLiveData<KeysVersion> = MutableLiveData()
var loadingStatus: MutableLiveData<WaitingViewData> = MutableLiveData()
init {
recoveryKey.value = null
isCreatingBackupVersion.value = false
prepareRecoverFailError.value = null
creatingBackupError.value = null
loadingStatus.value = null
}
var loadingStatus: MutableLiveData<WaitingViewData?> = MutableLiveData(null)
fun initSession(session: Session) {
this.session = session

View file

@ -21,8 +21,10 @@ import android.content.pm.ShortcutManager
import android.os.Build
import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutManagerCompat
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.dispatchers.CoroutineDispatchers
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.PinCodeStoreListener
import kotlinx.coroutines.CoroutineScope
@ -43,6 +45,7 @@ import javax.inject.Inject
class ShortcutsHandler @Inject constructor(
private val context: Context,
private val stringProvider: StringProvider,
private val appDispatchers: CoroutineDispatchers,
private val shortcutCreator: ShortcutCreator,
private val activeSessionHolder: ActiveSessionHolder,
@ -72,7 +75,7 @@ class ShortcutsHandler @Inject constructor(
.onCompletion { pinCodeStore.removeListener(this@ShortcutsHandler) }
.onEach { rooms ->
// Remove dead shortcuts (i.e. deleted rooms)
removeDeadShortcut(rooms.map { it.roomId })
removeDeadShortcuts(rooms.map { it.roomId })
// Create shortcuts
createShortcuts(rooms)
@ -81,7 +84,7 @@ class ShortcutsHandler @Inject constructor(
.launchIn(coroutineScope)
}
private fun removeDeadShortcut(roomIds: List<String>) {
private fun removeDeadShortcuts(roomIds: List<String>) {
val deadShortcutIds = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC)
.map { it.id }
.filter { !roomIds.contains(it) }
@ -91,7 +94,11 @@ class ShortcutsHandler @Inject constructor(
ShortcutManagerCompat.removeLongLivedShortcuts(context, deadShortcutIds)
if (isRequestPinShortcutSupported) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
context.getSystemService<ShortcutManager>()?.disableShortcuts(deadShortcutIds)
ShortcutManagerCompat.disableShortcuts(
context,
deadShortcutIds,
stringProvider.getString(R.string.shortcut_disabled_reason_room_left)
)
}
}
}
@ -130,8 +137,15 @@ class ShortcutsHandler @Inject constructor(
if (isRequestPinShortcutSupported) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
context.getSystemService<ShortcutManager>()
?.let {
it.disableShortcuts(it.pinnedShortcuts.map { pinnedShortcut -> pinnedShortcut.id })
?.pinnedShortcuts
?.takeIf { it.isNotEmpty() }
?.map { pinnedShortcut -> pinnedShortcut.id }
?.let { shortcutIdsToDisable ->
ShortcutManagerCompat.disableShortcuts(
context,
shortcutIdsToDisable,
stringProvider.getString(R.string.shortcut_disabled_reason_sign_out)
)
}
}
}

View file

@ -2102,12 +2102,12 @@ class RoomDetailFragment @Inject constructor(
// VectorInviteView.Callback
override fun onAcceptInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) }
roomDetailViewModel.handle(RoomDetailAction.AcceptInvite)
}
override fun onRejectInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) }
roomDetailViewModel.handle(RoomDetailAction.RejectInvite)
}

View file

@ -17,13 +17,15 @@
package im.vector.app.features.home.room.detail.composer
import android.content.ClipData
import android.content.Context
import android.net.Uri
import android.os.Build
import android.text.Editable
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.ViewCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import com.vanniktech.emoji.EmojiEditText
@ -43,23 +45,35 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
var callback: Callback? = null
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? {
val ic = super.onCreateInputConnection(editorInfo) ?: return null
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("*/*"))
var ic = super.onCreateInputConnection(editorInfo) ?: return null
val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this) ?: arrayOf("image/*")
val callback =
InputConnectionCompat.OnCommitContentListener { inputContentInfo, flags, _ ->
val lacksPermission = (flags and
InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && lacksPermission) {
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
return@OnCommitContentListener false
EditorInfoCompat.setContentMimeTypes(editorInfo, mimeTypes)
ic = InputConnectionCompat.createWrapper(this, ic, editorInfo)
val onReceiveContentListener = OnReceiveContentListener { _, payload ->
val split = payload.partition { item -> item.uri != null }
val uriContent = split.first
val remaining = split.second
if (uriContent != null) {
val clip: ClipData = uriContent.clip
for (i in 0 until clip.itemCount) {
val uri = clip.getItemAt(i).uri
// ... app-specific logic to handle the URI ...
callback?.onRichContentSelected(uri)
}
}
callback?.onRichContentSelected(inputContentInfo.contentUri) ?: false
// Return anything that we didn't handle ourselves. This preserves the default platform
// behavior for text and anything else for which we are not implementing custom handling.
// Return anything that we didn't handle ourselves. This preserves the default platform
// behavior for text and anything else for which we are not implementing custom handling.
remaining
}
return InputConnectionCompat.createWrapper(ic, editorInfo, callback)
ViewCompat.setOnReceiveContentListener(this, mimeTypes, onReceiveContentListener)
return ic
}
init {

View file

@ -482,7 +482,7 @@ class RoomListFragment @Inject constructor(
}
override fun onAcceptRoomInvitation(room: RoomSummary) {
notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(room.roomId) }
roomListViewModel.handle(RoomListAction.AcceptInvitation(room))
}
@ -495,7 +495,7 @@ class RoomListFragment @Inject constructor(
}
override fun onRejectRoomInvitation(room: RoomSummary) {
notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(room.roomId) }
roomListViewModel.handle(RoomListAction.RejectInvitation(room))
}
}

View file

@ -15,8 +15,10 @@
*/
package im.vector.app.features.notifications
import android.net.Uri
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.takeAs
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
@ -28,12 +30,15 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isEdition
import org.matrix.android.sdk.api.session.events.model.isImageMessage
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getEditedEventId
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import timber.log.Timber
@ -49,11 +54,12 @@ import javax.inject.Inject
class NotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider,
private val noticeEventFormatter: NoticeEventFormatter,
private val displayableEventFormatter: DisplayableEventFormatter) {
private val displayableEventFormatter: DisplayableEventFormatter
) {
// private val eventDisplay = RiotEventDisplay(context)
fun resolveEvent(event: Event/*, roomState: RoomState?, bingRule: PushRule?*/, session: Session, isNoisy: Boolean): NotifiableEvent? {
suspend fun resolveEvent(event: Event/*, roomState: RoomState?, bingRule: PushRule?*/, session: Session, isNoisy: Boolean): NotifiableEvent? {
val roomID = event.roomId ?: return null
val eventId = event.eventId ?: return null
if (event.getClearType() == EventType.STATE_ROOM_MEMBER) {
@ -89,7 +95,7 @@ class NotifiableEventResolver @Inject constructor(
}
}
fun resolveInMemoryEvent(session: Session, event: Event, canBeReplaced: Boolean): NotifiableEvent? {
suspend fun resolveInMemoryEvent(session: Session, event: Event, canBeReplaced: Boolean): NotifiableEvent? {
if (event.getClearType() != EventType.MESSAGE) return null
// Ignore message edition
@ -120,7 +126,7 @@ class NotifiableEventResolver @Inject constructor(
}
}
private fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent {
private suspend fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent {
// The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...)
val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/)
@ -140,6 +146,7 @@ class NotifiableEventResolver @Inject constructor(
senderName = senderDisplayName,
senderId = event.root.senderId,
body = body.toString(),
imageUri = event.fetchImageIfPresent(session),
roomId = event.root.roomId!!,
roomName = roomName,
matrixID = session.myUserId
@ -173,6 +180,7 @@ class NotifiableEventResolver @Inject constructor(
senderName = senderDisplayName,
senderId = event.root.senderId,
body = body,
imageUri = event.fetchImageIfPresent(session),
roomId = event.root.roomId!!,
roomName = roomName,
roomIsDirect = room.roomSummary()?.isDirect ?: false,
@ -192,6 +200,26 @@ class NotifiableEventResolver @Inject constructor(
}
}
private suspend fun TimelineEvent.fetchImageIfPresent(session: Session): Uri? {
return when {
root.isEncrypted() && root.mxDecryptionResult == null -> null
root.isImageMessage() -> downloadAndExportImage(session)
else -> null
}
}
private suspend fun TimelineEvent.downloadAndExportImage(session: Session): Uri? {
return kotlin.runCatching {
getLastMessageContent()?.takeAs<MessageWithAttachmentContent>()?.let { imageMessage ->
val fileService = session.fileService()
fileService.downloadFile(imageMessage)
fileService.getTemporarySharableURI(imageMessage)
}
}.onFailure {
Timber.e(it, "Failed to download and export image for notification")
}.getOrNull()
}
private fun resolveStateRoomEvent(event: Event, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? {
val content = event.content?.toModel<RoomMemberContent>() ?: return null
val roomId = event.roomId ?: return null

View file

@ -15,6 +15,7 @@
*/
package im.vector.app.features.notifications
import android.net.Uri
import org.matrix.android.sdk.api.session.events.model.EventType
data class NotifiableMessageEvent(
@ -26,6 +27,7 @@ data class NotifiableMessageEvent(
val senderName: String?,
val senderId: String?,
val body: String?,
val imageUri: Uri?,
val roomId: String,
val roomName: String?,
val roomIsDirect: Boolean = false,

View file

@ -49,26 +49,26 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
NotificationUtils.SMART_REPLY_ACTION ->
handleSmartReply(intent, context)
NotificationUtils.DISMISS_ROOM_NOTIF_ACTION ->
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMessageEventOfRoom(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
}
NotificationUtils.DISMISS_SUMMARY_ACTION ->
notificationDrawerManager.clearAllEvents()
NotificationUtils.MARK_ROOM_READ_ACTION ->
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMessageEventOfRoom(it)
handleMarkAsRead(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
handleMarkAsRead(roomId)
}
NotificationUtils.JOIN_ACTION -> {
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMemberShipNotificationForRoom(it)
handleJoinRoom(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleJoinRoom(roomId)
}
}
NotificationUtils.REJECT_ACTION -> {
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMemberShipNotificationForRoom(it)
handleRejectRoom(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleRejectRoom(roomId)
}
}
}
@ -138,6 +138,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
?: context?.getString(R.string.notification_sender_me),
senderId = session.myUserId,
body = message,
imageUri = null,
roomId = room.roomId,
roomName = room.roomSummary()?.displayName ?: room.roomId,
roomIsDirect = room.roomSummary()?.isDirect == true,
@ -145,8 +146,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
canBeReplaced = false
)
notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent)
notificationDrawerManager.refreshNotificationDrawer()
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) }
/*
// TODO Error cannot be managed the same way than in Riot

View file

@ -93,7 +93,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
#refreshNotificationDrawer() is called.
Events might be grouped and there might not be one notification per event!
*/
fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (!vectorPreferences.areNotificationEnabledForDevice()) {
Timber.i("Notification are disabled for this device")
return
@ -105,87 +105,15 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
} else {
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
}
synchronized(queuedEvents) {
val existing = queuedEvents.firstOrNull { it.eventId == notifiableEvent.eventId }
if (existing != null) {
if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than
// the fcm one (like type/content/clear text) (e.g when an encrypted message from
// FCM should be update with clear text after a sync)
// In this case the message has already been notified, and might have done some noise
// So we want the notification to be updated even if it has already been displayed
// Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating
queuedEvents.remove(existing)
queuedEvents.add(notifiableEvent)
} else {
// keep the existing one, do not replace
}
} else {
// Check if this is an edit
if (notifiableEvent.editedEventId != null) {
// This is an edition
val eventBeforeEdition = queuedEvents.firstOrNull {
// Edition of an event
it.eventId == notifiableEvent.editedEventId ||
// or edition of an edition
it.editedEventId == notifiableEvent.editedEventId
}
if (eventBeforeEdition != null) {
// Replace the existing notification with the new content
queuedEvents.remove(eventBeforeEdition)
queuedEvents.add(notifiableEvent)
} else {
// Ignore an edit of a not displayed event in the notification drawer
}
} else {
// Not an edit
if (seenEventIds.contains(notifiableEvent.eventId)) {
// we've already seen the event, lets skip
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
} else {
seenEventIds.put(notifiableEvent.eventId)
queuedEvents.add(notifiableEvent)
}
}
}
}
}
fun onEventRedacted(eventId: String) {
synchronized(queuedEvents) {
queuedEvents.replace(eventId) {
when (it) {
is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
}
}
}
add(notifiableEvent, seenEventIds)
}
/**
* Clear all known events and refresh the notification drawer
*/
fun clearAllEvents() {
synchronized(queuedEvents) {
queuedEvents.clear()
}
refreshNotificationDrawer()
}
/** Clear all known message events for this room */
fun clearMessageEventOfRoom(roomId: String?) {
Timber.v("clearMessageEventOfRoom $roomId")
if (roomId != null) {
val shouldUpdate = removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
if (shouldUpdate) {
refreshNotificationDrawer()
}
}
updateEvents { it.clear() }
}
/**
@ -193,32 +121,36 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
*/
fun setCurrentRoom(roomId: String?) {
var hasChanged: Boolean
synchronized(queuedEvents) {
hasChanged = roomId != currentRoomId
updateEvents {
val hasChanged = roomId != currentRoomId
currentRoomId = roomId
if (hasChanged && roomId != null) {
it.clearMessagesForRoom(roomId)
}
if (hasChanged) {
clearMessageEventOfRoom(roomId)
}
}
fun clearMemberShipNotificationForRoom(roomId: String) {
val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
if (shouldUpdate) {
refreshNotificationDrawerBg()
fun notificationStyleChanged() {
updateEvents {
val newSettings = vectorPreferences.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications
notificationDisplayer.cancelAllNotifications()
useCompleteNotificationFormat = newSettings
}
}
}
private fun removeAll(predicate: (NotifiableEvent) -> Boolean): Boolean {
return synchronized(queuedEvents) {
queuedEvents.removeAll(predicate)
fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
synchronized(queuedEvents) {
action(this, queuedEvents)
}
refreshNotificationDrawer()
}
private var firstThrottler = FirstThrottler(200)
fun refreshNotificationDrawer() {
private fun refreshNotificationDrawer() {
// Implement last throttler
val canHandle = firstThrottler.canHandle()
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
@ -239,18 +171,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
@WorkerThread
private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()")
val newSettings = vectorPreferences.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications
notificationDisplayer.cancelAllNotifications()
useCompleteNotificationFormat = newSettings
}
val eventsToRender = synchronized(queuedEvents) {
notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also {
queuedEvents.clear()
queuedEvents.addAll(it.onlyKeptEvents())
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, renderedEvents).also {
queuedEvents.clearAndAdd(it.onlyKeptEvents())
}
}
@ -286,7 +209,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile()
FileOutputStream(file).use {
currentSession?.securelyStoreObject(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it)
currentSession?.securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info")
@ -294,21 +217,21 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
}
}
private fun loadEventInfo(): MutableList<NotifiableEvent> {
private fun loadEventInfo(): NotificationEventQueue {
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = currentSession?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) {
return events.toMutableList()
return NotificationEventQueue(events.toMutableList())
}
}
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to load cached notification info")
}
return ArrayList()
return NotificationEventQueue()
}
private fun deleteCachedRoomNotifications() {
@ -330,11 +253,3 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
}
}
private fun MutableList<NotifiableEvent>.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) {
val indexToReplace = indexOfFirst { it.eventId == eventId }
if (indexToReplace == -1) {
return
}
set(indexToReplace, block(get(indexToReplace)))
}

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.notifications
import timber.log.Timber
class NotificationEventQueue(
private val queue: MutableList<NotifiableEvent> = mutableListOf()
) {
fun markRedacted(eventIds: List<String>) {
eventIds.forEach { redactedId ->
queue.replace(redactedId) {
when (it) {
is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
}
}
}
}
fun syncRoomEvents(roomsLeft: Collection<String>, roomsJoined: Collection<String>) {
if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) {
queue.removeAll {
when (it) {
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
else -> false
}
}
}
}
fun isEmpty() = queue.isEmpty()
fun clearAndAdd(events: List<NotifiableEvent>) {
queue.clear()
queue.addAll(events)
}
fun clear() {
queue.clear()
}
fun add(notifiableEvent: NotifiableEvent, seenEventIds: CircularCache<String>) {
val existing = findExistingById(notifiableEvent)
val edited = findEdited(notifiableEvent)
when {
existing != null -> {
if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than
// the fcm one (like type/content/clear text) (e.g when an encrypted message from
// FCM should be update with clear text after a sync)
// In this case the message has already been notified, and might have done some noise
// So we want the notification to be updated even if it has already been displayed
// Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating
replace(replace = existing, with = notifiableEvent)
} else {
// keep the existing one, do not replace
}
}
edited != null -> {
// Replace the existing notification with the new content
replace(replace = edited, with = notifiableEvent)
}
seenEventIds.contains(notifiableEvent.eventId) -> {
// we've already seen the event, lets skip
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
}
else -> {
seenEventIds.put(notifiableEvent.eventId)
queue.add(notifiableEvent)
}
}
}
private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return queue.firstOrNull { it.eventId == notifiableEvent.eventId }
}
private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return notifiableEvent.editedEventId?.let { editedId ->
queue.firstOrNull {
it.eventId == editedId || it.editedEventId == editedId
}
}
}
private fun replace(replace: NotifiableEvent, with: NotifiableEvent) {
queue.remove(replace)
queue.add(with)
}
fun clearMemberShipNotificationForRoom(roomId: String) {
Timber.v("clearMemberShipOfRoom $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
}
fun clearMessagesForRoom(roomId: String) {
Timber.v("clearMessageEventOfRoom $roomId")
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
}
fun rawEvents(): List<NotifiableEvent> = queue
}
private fun MutableList<NotifiableEvent>.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) {
val indexToReplace = indexOfFirst { it.eventId == eventId }
if (indexToReplace == -1) {
return
}
set(indexToReplace, block(get(indexToReplace)))
}

View file

@ -17,7 +17,6 @@
package im.vector.app.features.notifications
import android.app.Notification
import androidx.core.content.pm.ShortcutInfoCompat
import javax.inject.Inject
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
@ -104,7 +103,7 @@ class NotificationFactory @Inject constructor(
sealed interface RoomNotification {
data class Removed(val roomId: String) : RoomNotification
data class Message(val notification: Notification, val shortcutInfo: ShortcutInfoCompat?, val meta: Meta) : RoomNotification {
data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
data class Meta(
val summaryLine: CharSequence,
val messageCount: Int,

View file

@ -17,7 +17,6 @@ package im.vector.app.features.notifications
import android.content.Context
import androidx.annotation.WorkerThread
import androidx.core.content.pm.ShortcutManagerCompat
import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID
import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID
import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID
@ -63,9 +62,6 @@ class NotificationRenderer @Inject constructor(private val notificationDisplayer
}
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
wrapper.shortcutInfo?.let {
ShortcutManagerCompat.pushDynamicShortcut(appContext, it)
}
notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification)
}
}

View file

@ -48,6 +48,7 @@ import androidx.fragment.app.Fragment
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.createIgnoredUri
import im.vector.app.core.platform.PendingIntentCompat
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.services.CallService
import im.vector.app.core.utils.startNotificationChannelSettingsIntent
@ -227,7 +228,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
// build the pending intent go to the home screen if this is clicked.
val i = HomeActivity.newIntent(context)
i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
val pi = PendingIntent.getActivity(context, 0, i, 0)
val pi = PendingIntent.getActivity(context, 0, i, PendingIntentCompat.FLAG_IMMUTABLE)
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
@ -320,16 +321,23 @@ class NotificationUtils @Inject constructor(private val context: Context,
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
data = createIgnoredUri(call.callId)
}
val contentPendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), contentIntent, 0)
val contentPendingIntent = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
contentIntent,
PendingIntentCompat.FLAG_IMMUTABLE
)
val answerCallPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(VectorCallActivity.newIntent(
.addNextIntent(
VectorCallActivity.newIntent(
context = context,
call = call,
mode = VectorCallActivity.INCOMING_ACCEPT)
mode = VectorCallActivity.INCOMING_ACCEPT
)
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT)
)
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE)
val rejectCallPendingIntent = buildRejectCallPendingIntent(call.callId)
@ -338,7 +346,8 @@ class NotificationUtils @Inject constructor(private val context: Context,
IconCompat.createWithResource(context, R.drawable.ic_call_hangup)
.setTint(ThemeUtils.getColor(context, R.attr.colorError)),
getActionText(R.string.call_notification_reject, R.attr.colorError),
rejectCallPendingIntent)
rejectCallPendingIntent
)
)
builder.addAction(
@ -381,7 +390,12 @@ class NotificationUtils @Inject constructor(private val context: Context,
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
data = createIgnoredUri(call.callId)
}
val contentPendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), contentIntent, 0)
val contentPendingIntent = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
contentIntent,
PendingIntentCompat.FLAG_IMMUTABLE
)
val rejectCallPendingIntent = buildRejectCallPendingIntent(call.callId)
@ -390,7 +404,8 @@ class NotificationUtils @Inject constructor(private val context: Context,
IconCompat.createWithResource(context, R.drawable.ic_call_hangup)
.setTint(ThemeUtils.getColor(context, R.attr.colorError)),
getActionText(R.string.call_notification_hangup, R.attr.colorError),
rejectCallPendingIntent)
rejectCallPendingIntent
)
)
builder.setContentIntent(contentPendingIntent)
@ -431,13 +446,14 @@ class NotificationUtils @Inject constructor(private val context: Context,
IconCompat.createWithResource(context, R.drawable.ic_call_hangup)
.setTint(ThemeUtils.getColor(context, R.attr.colorError)),
getActionText(R.string.call_notification_hangup, R.attr.colorError),
rejectCallPendingIntent)
rejectCallPendingIntent
)
)
val contentPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(VectorCallActivity.newIntent(context, call, null))
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT)
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE)
builder.setContentIntent(contentPendingIntent)
@ -453,7 +469,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
context,
System.currentTimeMillis().toInt(),
rejectCallActionReceiver,
PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
}
@ -499,7 +515,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
val contentPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(RoomDetailActivity.newIntent(context, RoomDetailArgs(callInformation.nativeRoomId)))
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT)
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE)
builder.setContentIntent(contentPendingIntent)
return builder.build()
@ -517,7 +533,10 @@ class NotificationUtils @Inject constructor(private val context: Context,
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
PendingIntent.getActivity(
context, System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT
context,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
).let {
setContentIntent(it)
}
@ -587,8 +606,12 @@ class NotificationUtils @Inject constructor(private val context: Context,
markRoomReadIntent.action = MARK_ROOM_READ_ACTION
markRoomReadIntent.data = createIgnoredUri(roomInfo.roomId)
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
val markRoomReadPendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), markRoomReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT)
val markRoomReadPendingIntent = PendingIntent.getBroadcast(
context,
System.currentTimeMillis().toInt(),
markRoomReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
NotificationCompat.Action.Builder(R.drawable.ic_material_done_all_white,
stringProvider.getString(R.string.action_mark_room_read), markRoomReadPendingIntent)
@ -624,8 +647,12 @@ class NotificationUtils @Inject constructor(private val context: Context,
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
intent.action = DISMISS_ROOM_NOTIF_ACTION
val pendingIntent = PendingIntent.getBroadcast(context.applicationContext,
System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT)
val pendingIntent = PendingIntent.getBroadcast(
context.applicationContext,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
setDeleteIntent(pendingIntent)
}
.setTicker(tickerText)
@ -655,31 +682,41 @@ class NotificationUtils @Inject constructor(private val context: Context,
rejectIntent.action = REJECT_ACTION
rejectIntent.data = createIgnoredUri("$roomId&$matrixId")
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val rejectIntentPendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), rejectIntent,
PendingIntent.FLAG_UPDATE_CURRENT)
val rejectIntentPendingIntent = PendingIntent.getBroadcast(
context,
System.currentTimeMillis().toInt(),
rejectIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
addAction(
R.drawable.vector_notification_reject_invitation,
stringProvider.getString(R.string.reject),
rejectIntentPendingIntent)
rejectIntentPendingIntent
)
// offer to type a quick accept button
val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java)
joinIntent.action = JOIN_ACTION
joinIntent.data = createIgnoredUri("$roomId&$matrixId")
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val joinIntentPendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), joinIntent,
PendingIntent.FLAG_UPDATE_CURRENT)
val joinIntentPendingIntent = PendingIntent.getBroadcast(
context,
System.currentTimeMillis().toInt(),
joinIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
addAction(
R.drawable.vector_notification_accept_invitation,
stringProvider.getString(R.string.join),
joinIntentPendingIntent)
joinIntentPendingIntent
)
val contentIntent = HomeActivity.newIntent(context, inviteNotificationRoomId = inviteNotifiableEvent.roomId)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, 0))
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
if (inviteNotifiableEvent.noisy) {
// Compat
@ -718,7 +755,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(simpleNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, 0))
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
if (simpleNotifiableEvent.noisy) {
// Compat
@ -745,14 +782,22 @@ class NotificationUtils @Inject constructor(private val context: Context,
return TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(roomIntentTap)
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT)
.getPendingIntent(
System.currentTimeMillis().toInt(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
}
private fun buildOpenHomePendingIntentForSummary(): PendingIntent {
val intent = HomeActivity.newIntent(context, clearNotification = true)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.data = createIgnoredUri("tapSummary")
return PendingIntent.getActivity(context, Random.nextInt(1000), intent, PendingIntent.FLAG_UPDATE_CURRENT)
return PendingIntent.getActivity(
context,
Random.nextInt(1000),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
}
/*
@ -769,8 +814,13 @@ class NotificationUtils @Inject constructor(private val context: Context,
intent.action = SMART_REPLY_ACTION
intent.data = createIgnoredUri(roomId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
return PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), intent,
PendingIntent.FLAG_UPDATE_CURRENT)
return PendingIntent.getBroadcast(
context,
System.currentTimeMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
} else {
/*
TODO
@ -783,7 +833,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
// the action must be unique else the parameters are ignored
quickReplyIntent.action = QUICK_LAUNCH_ACTION
quickReplyIntent.data = createIgnoredUri($roomId")
return PendingIntent.getActivity(context, 0, quickReplyIntent, 0)
return PendingIntent.getActivity(context, 0, quickReplyIntent, PendingIntentCompat.FLAG_IMMUTABLE)
}
*/
}
@ -837,8 +887,12 @@ class NotificationUtils @Inject constructor(private val context: Context,
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = DISMISS_SUMMARY_ACTION
intent.data = createIgnoredUri("deleteSummary")
return PendingIntent.getBroadcast(context.applicationContext,
0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
return PendingIntent.getBroadcast(
context.applicationContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
}
fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
@ -875,7 +929,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
context,
0,
testActionIntent,
PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
notificationManager.notify(

View file

@ -16,10 +16,15 @@
package im.vector.app.features.notifications
import org.matrix.android.sdk.api.pushrules.Action
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.pushrules.PushEvents
import org.matrix.android.sdk.api.pushrules.PushRuleService
import org.matrix.android.sdk.api.pushrules.getActions
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.Event
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -31,43 +36,34 @@ class PushRuleTriggerListener @Inject constructor(
) : PushRuleService.PushRuleListener {
private var session: Session? = null
private val scope: CoroutineScope = CoroutineScope(SupervisorJob())
override fun onMatchRule(event: Event, actions: List<Action>) {
override fun onEvents(pushEvents: PushEvents) {
scope.launch {
session?.let { session ->
val notifiableEvents = createNotifiableEvents(pushEvents, session)
notificationDrawerManager.updateEvents { queuedEvents ->
notifiableEvents.forEach { notifiableEvent ->
queuedEvents.onNotifiableEventReceived(notifiableEvent)
}
queuedEvents.syncRoomEvents(roomsLeft = pushEvents.roomsLeft, roomsJoined = pushEvents.roomsJoined)
queuedEvents.markRedacted(pushEvents.redactedEventIds)
}
} ?: Timber.e("Called without active session")
}
}
private suspend fun createNotifiableEvents(pushEvents: PushEvents, session: Session): List<NotifiableEvent> {
return pushEvents.matchedEvents.mapNotNull { (event, pushRule) ->
Timber.v("Push rule match for event ${event.eventId}")
val safeSession = session ?: return Unit.also {
Timber.e("Called without active session")
}
val notificationAction = actions.toNotificationAction()
if (notificationAction.shouldNotify) {
val notifiableEvent = resolver.resolveEvent(event, safeSession, isNoisy = !notificationAction.soundName.isNullOrBlank())
if (notifiableEvent == null) {
Timber.v("## Failed to resolve event")
// TODO
} else {
Timber.v("New event to notify")
notificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
}
val action = pushRule.getActions().toNotificationAction()
if (action.shouldNotify) {
resolver.resolveEvent(event, session, isNoisy = !action.soundName.isNullOrBlank())
} else {
Timber.v("Matched push rule is set to not notify")
null
}
}
override fun onRoomLeft(roomId: String) {
notificationDrawerManager.clearMessageEventOfRoom(roomId)
notificationDrawerManager.clearMemberShipNotificationForRoom(roomId)
}
override fun onRoomJoined(roomId: String) {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomId)
}
override fun onEventRedacted(redactedEventId: String) {
notificationDrawerManager.onEventRedacted(redactedEventId)
}
override fun batchFinish() {
notificationDrawerManager.refreshNotificationDrawer()
}
fun startWithSession(session: Session) {
@ -79,6 +75,7 @@ class PushRuleTriggerListener @Inject constructor(
}
fun stop() {
scope.coroutineContext.cancelChildren(CancellationException("PushRuleTriggerListener stopping"))
session?.removePushRuleListener(this)
session = null
notificationDrawerManager.clearAllEvents()

View file

@ -18,14 +18,10 @@ package im.vector.app.features.notifications
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.graphics.drawable.IconCompat
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.room.detail.RoomDetailActivity
import me.gujun.android.span.Span
import me.gujun.android.span.span
import timber.log.Timber
@ -61,17 +57,6 @@ class RoomGroupMessageCreator @Inject constructor(
}
val largeBitmap = getRoomBitmap(events)
val shortcutInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val openRoomIntent = RoomDetailActivity.shortcutIntent(appContext, roomId)
ShortcutInfoCompat.Builder(appContext, roomId)
.setLongLived(true)
.setIntent(openRoomIntent)
.setShortLabel(roomName)
.setIcon(largeBitmap?.let { IconCompat.createWithAdaptiveBitmap(it) } ?: iconLoader.getUserIcon(events.last().senderAvatarPath))
.build()
} else {
null
}
val lastMessageTimestamp = events.last().timestamp
val smartReplyErrors = events.filter { it.isSmartReplyError() }
@ -96,7 +81,6 @@ class RoomGroupMessageCreator @Inject constructor(
userDisplayName,
tickerText
),
shortcutInfo,
meta
)
}
@ -114,7 +98,14 @@ class RoomGroupMessageCreator @Inject constructor(
}
when {
event.isSmartReplyError() -> addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson)
else -> addMessage(event.body, event.timestamp, senderPerson)
else -> {
val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
}
addMessage(message)
}
}
}
}

View file

@ -17,11 +17,10 @@
package im.vector.app.features.pin
import android.os.SystemClock
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import im.vector.app.features.settings.VectorPreferences
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@ -41,7 +40,7 @@ private const val PERIOD_OF_GRACE_IN_MS = 2 * 60 * 1000L
class PinLocker @Inject constructor(
private val pinCodeStore: PinCodeStore,
private val vectorPreferences: VectorPreferences
) : LifecycleObserver {
) : DefaultLifecycleObserver {
enum class State {
// App is locked, can be unlock
@ -87,16 +86,14 @@ class PinLocker @Inject constructor(
computeState()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun entersForeground() {
override fun onResume(owner: LifecycleOwner) {
val timeElapsedSinceBackground = SystemClock.elapsedRealtime() - entersBackgroundTs
shouldBeLocked = shouldBeLocked || timeElapsedSinceBackground >= getGracePeriod()
Timber.v("App enters foreground after $timeElapsedSinceBackground ms spent in background shouldBeLocked: $shouldBeLocked")
computeState()
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun entersBackground() {
override fun onPause(owner: LifecycleOwner) {
Timber.v("App enters background")
entersBackgroundTs = SystemClock.elapsedRealtime()
}

View file

@ -61,8 +61,8 @@ class CreatePollFragment @Inject constructor(
viewModel.handle(CreatePollAction.OnCreatePoll)
}
viewModel.subscribe(this) {
views.createPollButton.isEnabled = it.canCreatePoll
viewModel.onEach(CreatePollViewState::canCreatePoll) { canCreatePoll ->
views.createPollButton.isEnabled = canCreatePoll
}
viewModel.observeViewEvents {

View file

@ -46,7 +46,7 @@ class RageShake @Inject constructor(private val activity: FragmentActivity,
shakeDetector = ShakeDetector(this).apply {
setSensitivity(vectorPreferences.getRageshakeSensitivity())
start(sensorManager)
start(sensorManager, SensorManager.SENSOR_DELAY_GAME)
}
}

View file

@ -55,7 +55,7 @@ class VectorSettingsPinFragment @Inject constructor(
useCompleteNotificationPref.setOnPreferenceChangeListener { _, _ ->
// Refresh the drawer for an immediate effect of this change
notificationDrawerManager.refreshNotificationDrawer()
notificationDrawerManager.notificationStyleChanged()
true
}
}

View file

@ -23,7 +23,7 @@ import java.io.File
import java.io.FileOutputStream
abstract class AbstractVoiceRecorder(
context: Context,
private val context: Context,
private val filenameExt: String
) : VoiceRecorder {
private val outputDirectory: File by lazy {
@ -39,7 +39,7 @@ abstract class AbstractVoiceRecorder(
abstract fun convertFile(recordedFile: File?): File?
private fun init() {
MediaRecorder().let {
createMediaRecorder().let {
it.setAudioSource(MediaRecorder.AudioSource.DEFAULT)
setOutputFormat(it)
it.setAudioEncodingBitRate(24000)
@ -48,6 +48,15 @@ abstract class AbstractVoiceRecorder(
}
}
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
override fun startRecord() {
init()
outputFile = File(outputDirectory, "Voice message.$filenameExt")

View file

@ -69,11 +69,11 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toTopOf="@id/createPollButton"
app:layout_constraintTop_toBottomOf="@+id/appBarLayout"
tools:listitem="@layout/item_profile_action" />
tools:listitem="@layout/item_form_text_input_with_delete" />
<Button
android:id="@+id/createPollButton"
style="@style/Widget.Vector.Button.CreatePoll"
style="@style/Widget.Vector.Button.CallToAction"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_margin="16dp"
@ -92,7 +92,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@string/voice_message_release_to_send_toast"
tools:text="@string/create_poll_empty_question_error"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -9,7 +9,7 @@
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/formTextInputTextInputLayout"
style="@style/Widget.Vector.EditText.Form"
style="@style/Widget.Vector.TextInputLayout.Form"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"

View file

@ -9,7 +9,7 @@
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/formTextInputTextInputLayout"
style="@style/Widget.Vector.EditText.Form"
style="@style/Widget.Vector.TextInputLayout.Form"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"

View file

@ -175,7 +175,7 @@
android:weightSum="3">
<LinearLayout
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"

File diff suppressed because one or more lines are too long

View file

@ -1080,6 +1080,9 @@
<string name="room_settings_forget">Forget</string>
<string name="room_settings_add_homescreen_shortcut">Add to Home screen</string>
<string name="shortcut_disabled_reason_room_left">The room has been left!</string>
<string name="shortcut_disabled_reason_sign_out">The session has been signed out!</string>
<!-- home sliding menu -->
<string name="room_sliding_menu_messages">Messages</string>
<string name="room_sliding_menu_settings">Settings</string>

View file

@ -19,6 +19,9 @@ package im.vector.app.features.notifications
import im.vector.app.features.notifications.ProcessedEvent.Type
import im.vector.app.test.fakes.FakeAutoAcceptInvites
import im.vector.app.test.fakes.FakeOutdatedEventDetector
import im.vector.app.test.fixtures.aNotifiableMessageEvent
import im.vector.app.test.fixtures.aSimpleNotifiableEvent
import im.vector.app.test.fixtures.anInviteNotifiableEvent
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.EventType
@ -145,48 +148,3 @@ class NotifiableEventProcessorTest {
ProcessedEvent(it.first, it.second)
}
}
fun aSimpleNotifiableEvent(eventId: String, type: String? = null) = SimpleNotifiableEvent(
matrixID = null,
eventId = eventId,
editedEventId = null,
noisy = false,
title = "title",
description = "description",
type = type,
timestamp = 0,
soundName = null,
canBeReplaced = false,
isRedacted = false
)
fun anInviteNotifiableEvent(roomId: String) = InviteNotifiableEvent(
matrixID = null,
eventId = "event-id",
roomId = roomId,
roomName = "a room name",
editedEventId = null,
noisy = false,
title = "title",
description = "description",
type = null,
timestamp = 0,
soundName = null,
canBeReplaced = false,
isRedacted = false
)
fun aNotifiableMessageEvent(eventId: String, roomId: String) = NotifiableMessageEvent(
eventId = eventId,
editedEventId = null,
noisy = false,
timestamp = 0,
senderName = "sender-name",
senderId = "sending-id",
body = "message-body",
roomId = roomId,
roomName = "room-name",
roomIsDirect = false,
canBeReplaced = false,
isRedacted = false
)

View file

@ -0,0 +1,216 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.notifications
import im.vector.app.test.fixtures.aNotifiableMessageEvent
import im.vector.app.test.fixtures.aSimpleNotifiableEvent
import im.vector.app.test.fixtures.anInviteNotifiableEvent
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
class NotificationEventQueueTest {
private val seenIdsCache = CircularCache.create<String>(5)
@Test
fun `given events when redacting some then marks matching event ids as redacted`() {
val queue = givenQueue(listOf(
aSimpleNotifiableEvent(eventId = "redacted-id-1"),
aNotifiableMessageEvent(eventId = "redacted-id-2"),
anInviteNotifiableEvent(eventId = "redacted-id-3"),
aSimpleNotifiableEvent(eventId = "kept-id"),
))
queue.markRedacted(listOf("redacted-id-1", "redacted-id-2", "redacted-id-3"))
queue.rawEvents() shouldBeEqualTo listOf(
aSimpleNotifiableEvent(eventId = "redacted-id-1", isRedacted = true),
aNotifiableMessageEvent(eventId = "redacted-id-2", isRedacted = true),
anInviteNotifiableEvent(eventId = "redacted-id-3", isRedacted = true),
aSimpleNotifiableEvent(eventId = "kept-id", isRedacted = false),
)
}
@Test
fun `given invite event when leaving invited room and syncing then removes event`() {
val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = "a-room-id")))
val roomsLeft = listOf("a-room-id")
queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given invite event when joining invited room and syncing then removes event`() {
val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = "a-room-id")))
val joinedRooms = listOf("a-room-id")
queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms)
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given message event when leaving message room and syncing then removes event`() {
val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = "a-room-id")))
val roomsLeft = listOf("a-room-id")
queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given events when syncing without rooms left or joined ids then does not change the events`() {
val queue = givenQueue(listOf(
aNotifiableMessageEvent(roomId = "a-room-id"),
anInviteNotifiableEvent(roomId = "a-room-id")
))
queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList())
queue.rawEvents() shouldBeEqualTo listOf(
aNotifiableMessageEvent(roomId = "a-room-id"),
anInviteNotifiableEvent(roomId = "a-room-id")
)
}
@Test
fun `given events then is not empty`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.isEmpty() shouldBeEqualTo false
}
@Test
fun `given no events then is empty`() {
val queue = givenQueue(emptyList())
queue.isEmpty() shouldBeEqualTo true
}
@Test
fun `given events when clearing and adding then removes previous events and adds only new events`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.clearAndAdd(listOf(anInviteNotifiableEvent()))
queue.rawEvents() shouldBeEqualTo listOf(anInviteNotifiableEvent())
}
@Test
fun `when clearing then is empty`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.clear()
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given no events when adding then adds event`() {
val queue = givenQueue(listOf())
queue.add(aSimpleNotifiableEvent(), seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(aSimpleNotifiableEvent())
}
@Test
fun `given no events when adding already seen event then ignores event`() {
val queue = givenQueue(listOf())
val notifiableEvent = aSimpleNotifiableEvent()
seenIdsCache.put(notifiableEvent.eventId)
queue.add(notifiableEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given replaceable event when adding event with same id then updates existing event`() {
val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true)
val updatedEvent = replaceableEvent.copy(title = "updated title")
val queue = givenQueue(listOf(replaceableEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@Test
fun `given non replaceable event when adding event with same id then ignores event`() {
val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false)
val updatedEvent = nonReplaceableEvent.copy(title = "updated title")
val queue = givenQueue(listOf(nonReplaceableEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(nonReplaceableEvent)
}
@Test
fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() {
val editedEvent = aSimpleNotifiableEvent(eventId = "id-to-edit")
val updatedEvent = editedEvent.copy(eventId = "1", editedEventId = "id-to-edit", title = "updated title")
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@Test
fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() {
val editedEvent = aSimpleNotifiableEvent(eventId = "0", editedEventId = "id-to-edit")
val updatedEvent = editedEvent.copy(eventId = "1", editedEventId = "id-to-edit", title = "updated title")
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@Test
fun `when clearing membership notification then removes invite events with matching room id`() {
val roomId = "a-room-id"
val queue = givenQueue(listOf(
anInviteNotifiableEvent(roomId = roomId),
aNotifiableMessageEvent(roomId = roomId)
))
queue.clearMemberShipNotificationForRoom(roomId)
queue.rawEvents() shouldBeEqualTo listOf(aNotifiableMessageEvent(roomId = roomId))
}
@Test
fun `when clearing messages for room then removes message events with matching room id`() {
val roomId = "a-room-id"
val queue = givenQueue(listOf(
anInviteNotifiableEvent(roomId = roomId),
aNotifiableMessageEvent(roomId = roomId)
))
queue.clearMessagesForRoom(roomId)
queue.rawEvents() shouldBeEqualTo listOf(anInviteNotifiableEvent(roomId = roomId))
}
private fun givenQueue(events: List<NotifiableEvent>) = NotificationEventQueue(events.toMutableList())
}

View file

@ -20,6 +20,9 @@ import im.vector.app.features.notifications.ProcessedEvent.Type
import im.vector.app.test.fakes.FakeNotificationUtils
import im.vector.app.test.fakes.FakeRoomGroupMessageCreator
import im.vector.app.test.fakes.FakeSummaryGroupMessageCreator
import im.vector.app.test.fixtures.aNotifiableMessageEvent
import im.vector.app.test.fixtures.aSimpleNotifiableEvent
import im.vector.app.test.fixtures.anInviteNotifiableEvent
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test

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