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 - type: textarea
id: result id: result
attributes: attributes:
label: Intended result and actual result label: Outcome
placeholder: Tell us what went wrong placeholder: Tell us what went wrong
value: | value: |
#### What did you expect? #### What did you expect?

View file

@ -10,7 +10,7 @@ body:
id: usecase id: usecase
attributes: attributes:
label: Your use case 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! placeholder: Tell us what you would like to do!
value: | value: |
#### What would you like to do? #### What would you like to do?

View file

@ -18,7 +18,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
api-level: [28] api-level: [ 29 ]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with: with:
@ -56,7 +56,24 @@ jobs:
java-version: '11' java-version: '11'
- name: Run sanity tests on API ${{ matrix.api-level }} - name: Run sanity tests on API ${{ matrix.api-level }}
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
continue-on-error: true # allow pipeline to upload failure results
with: with:
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
api-level: ${{ matrix.api-level }} 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: automate-project-columns:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: alex-page/github-project-automation-plus@v0.8.1 - uses: alex-page/github-project-automation-plus@bb266ff4dde9242060e2d5418e120a133586d488
with: with:
project: Issue triage project: Issue triage
column: Incoming 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 package im.vector.lib.attachmentviewer
import android.annotation.SuppressLint
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle 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 // New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
window.setDecorFitsSystemWindows(false) window.setDecorFitsSystemWindows(false)
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE // New API instead of SYSTEM_UI_FLAG_IMMERSIVE
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE 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 // New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar) window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar)
// new API instead of FLAG_TRANSLUCENT_NAVIGATION // 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 // new API instead of SYSTEM_UI_FLAG_HIDE_NAVIGATION
window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars()) window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE // New API instead of SYSTEM_UI_FLAG_IMMERSIVE
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE 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 // New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar) window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar)
// New API instead of FLAG_TRANSLUCENT_NAVIGATION // 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 = [ ext.versions = [
'minSdk' : 21, 'minSdk' : 21,
'compileSdk' : 30, 'compileSdk' : 31,
'targetSdk' : 30, 'targetSdk' : 31,
'sourceCompat' : JavaVersion.VERSION_11, 'sourceCompat' : JavaVersion.VERSION_11,
'targetCompat' : JavaVersion.VERSION_11, 'targetCompat' : JavaVersion.VERSION_11,
] ]
@ -11,12 +11,12 @@ def gradle = "7.0.3"
// Ref: https://kotlinlang.org/releases.html // Ref: https://kotlinlang.org/releases.html
def kotlin = "1.5.31" def kotlin = "1.5.31"
def kotlinCoroutines = "1.5.2" def kotlinCoroutines = "1.5.2"
def dagger = "2.40" def dagger = "2.40.1"
def retrofit = "2.9.0" def retrofit = "2.9.0"
def arrow = "0.8.2" def arrow = "0.8.2"
def markwon = "4.6.2" def markwon = "4.6.2"
def moshi = "1.12.0" def moshi = "1.12.0"
def lifecycle = "2.2.0" def lifecycle = "2.4.0"
def flowBinding = "1.2.0" def flowBinding = "1.2.0"
def epoxy = "4.6.2" def epoxy = "4.6.2"
def mavericks = "2.4.0" def mavericks = "2.4.0"
@ -46,18 +46,18 @@ ext.libs = [
], ],
androidx : [ androidx : [
'appCompat' : "androidx.appcompat:appcompat:1.3.1", '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", 'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1",
'exifinterface' : "androidx.exifinterface:exifinterface:1.3.3", 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.3",
'fragmentKtx' : "androidx.fragment:fragment-ktx:1.3.6", 'fragmentKtx' : "androidx.fragment:fragment-ktx:1.3.6",
'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.1", '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", 'autoFill' : "androidx.autofill:autofill:1.1.0",
'preferenceKtx' : "androidx.preference:preference-ktx:1.1.1", 'preferenceKtx' : "androidx.preference:preference-ktx:1.1.1",
'junit' : "androidx.test.ext:junit:1.1.3", 'junit' : "androidx.test.ext:junit:1.1.3",
'lifecycleExtensions' : "androidx.lifecycle:lifecycle-extensions:$lifecycle", 'lifecycleCommon' : "androidx.lifecycle:lifecycle-common:$lifecycle",
'lifecycleJava8' : "androidx.lifecycle:lifecycle-common-java8:$lifecycle", 'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle",
'lifecycleLivedata' : "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1", 'lifecycleProcess' : "androidx.lifecycle:lifecycle-process:$lifecycle",
'datastore' : "androidx.datastore:datastore:1.0.0", 'datastore' : "androidx.datastore:datastore:1.0.0",
'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0", 'datastorepreferences' : "androidx.datastore:datastore-preferences:1.0.0",
'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2", 'pagingRuntimeKtx' : "androidx.paging:paging-runtime-ktx:2.1.2",

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=a8da5b02437a60819cad23e10fc7e9cf32bcb57029d9cb277e26eeff76ce014b distributionSha256Sum=00b273629df4ce46e68df232161d5a7c4e495b9a029ce6e0420f071e21316867
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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_light">@color/black_alpha</color>
<color name="vctr_chat_effect_snow_background_dark">@android:color/transparent</color> <color name="vctr_chat_effect_snow_background_dark">@android:color/transparent</color>
<attr name="vctr_voice_message_toast_background" format="color" /> <attr name="vctr_toast_background" format="color" />
<color name="vctr_voice_message_toast_background_light">@color/palette_black_900</color> <color name="vctr_toast_background_light">@color/palette_black_900</color>
<color name="vctr_voice_message_toast_background_dark">@color/palette_gray_400</color> <color name="vctr_toast_background_dark">@color/palette_gray_400</color>
<!-- Presence Indicator colors --> <!-- Presence Indicator colors -->
<attr name="vctr_presence_indicator_offline" format="color" /> <attr name="vctr_presence_indicator_offline" format="color" />

View file

@ -10,6 +10,11 @@
<item name="lineHeight">24sp</item> <item name="lineHeight">24sp</item>
</style> </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"> <style name="Widget.Vector.Button.Destructive">
<item name="android:minWidth">94dp</item> <item name="android:minWidth">94dp</item>
<item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayDestructive</item> <item name="materialThemeOverlay">@style/VectorMaterialThemeOverlayDestructive</item>

View file

@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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"> <style name="Widget.Vector.EditText.Composer" parent="Widget.AppCompat.EditText">
<item name="android:background">@android:color/transparent</item> <item name="android:background">@android:color/transparent</item>
<item name="android:inputType">textCapSentences|textMultiLine</item> <item name="android:inputType">textCapSentences|textMultiLine</item>
@ -19,9 +11,4 @@
<item name="android:textColor">?vctr_message_text_color</item> <item name="android:textColor">?vctr_message_text_color</item>
</style> </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> </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> <item name="direction">rightToLeft</item>
</style> </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> </resources>

View file

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

View file

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

View file

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

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

View file

@ -38,14 +38,22 @@ data class MXKey(
/** /**
* signature user Id to [deviceid][signature] * 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 * @return the signed data map
*/ */
fun signalableJSONDictionary(): Map<String, Any> { 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> * <pre>
* "signed_curve25519:AAAAFw": { * "signed_curve25519:AAAAFw": {
* "key": "IjwIcskng7YjYcn0tS8TUOT2OHHtBSfMpcfIczCgXj4", * "key": "IjwIcskng7YjYcn0tS8TUOT2OHHtBSfMpcfIczCgXj4",
* "fallback" : true|false
* "signatures": { * "signatures": {
* "@userId:matrix.org": { * "@userId:matrix.org": {
* "ed25519:GMJRREOASV": "EUjp6pXzK9u3SDFR\/qLbzpOi3bEREeI6qMnKzXu992HsfuDDZftfJfiUXv9b\/Hqq1og4qM\/vCQJGTHAWMmgkCg" * "ed25519:GMJRREOASV": "EUjp6pXzK9u3SDFR\/qLbzpOi3bEREeI6qMnKzXu992HsfuDDZftfJfiUXv9b\/Hqq1og4qM\/vCQJGTHAWMmgkCg"
@ -107,7 +116,8 @@ data class MXKey(
type = components[0], type = components[0],
keyId = components[1], keyId = components[1],
value = params["key"] as String, 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 androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.pushrules.Action 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.PushRuleService
import org.matrix.android.sdk.api.pushrules.RuleKind import org.matrix.android.sdk.api.pushrules.RuleKind
import org.matrix.android.sdk.api.pushrules.RuleScope import org.matrix.android.sdk.api.pushrules.RuleScope
@ -142,79 +143,6 @@ internal class DefaultPushRuleService @Inject constructor(
return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty() 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>> { override fun getKeywords(): LiveData<Set<String>> {
// Keywords are all content rules that don't start with '.' // Keywords are all content rules that don't start with '.'
val liveData = monarchy.findAllMappedWithChanges( val liveData = monarchy.findAllMappedWithChanges(
@ -229,4 +157,16 @@ internal class DefaultPushRuleService @Inject constructor(
results.firstOrNull().orEmpty().toSet() 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 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.pushrules.rest.PushRule
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isInvitation import org.matrix.android.sdk.api.session.events.model.isInvitation
@ -39,14 +40,6 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
) : ProcessEventForPushTask { ) : ProcessEventForPushTask {
override suspend fun execute(params: ProcessEventForPushTask.Params) { 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 val newJoinEvents = params.syncResponse.join
.mapNotNull { (key, value) -> .mapNotNull { (key, value) ->
value.timeline?.events?.mapNotNull { value.timeline?.events?.mapNotNull {
@ -74,10 +67,10 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
} }
Timber.v("[PushRules] Found ${allEvents.size} out of ${(newJoinEvents + inviteEvents).size}" + Timber.v("[PushRules] Found ${allEvents.size} out of ${(newJoinEvents + inviteEvents).size}" +
" to check for push rules with ${params.rules.size} rules") " to check for push rules with ${params.rules.size} rules")
allEvents.forEach { event -> val matchedEvents = allEvents.mapNotNull { event ->
pushRuleFinder.fulfilledBingRule(event, params.rules)?.let { pushRuleFinder.fulfilledBingRule(event, params.rules)?.let {
Timber.v("[PushRules] Rule $it match for event ${event.eventId}") 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") Timber.v("[PushRules] Found ${allRedactedEvents.size} redacted events")
allRedactedEvents.forEach { redactedEventId -> defaultPushRuleService.dispatchEvents(
defaultPushRuleService.dispatchRedactedEventId(redactedEventId) PushEvents(
} matchedEvents = matchedEvents,
roomsJoined = params.syncResponse.join.keys,
defaultPushRuleService.dispatchFinish() roomsLeft = params.syncResponse.leave.keys,
redactedEventIds = allRedactedEvents
)
)
} }
} }

View file

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

View file

@ -21,6 +21,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.ContactsContract import android.provider.ContactsContract
import im.vector.lib.multipicker.entity.MultiPickerContactType import im.vector.lib.multipicker.entity.MultiPickerContactType
import im.vector.lib.multipicker.utils.getColumnIndexOrNull
/** /**
* Contact Picker implementation * Contact Picker implementation
@ -49,9 +50,9 @@ class ContactPicker : Picker<MultiPickerContactType>() {
null null
)?.use { cursor -> )?.use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndex(ContactsContract.Contacts._ID) val idColumn = cursor.getColumnIndexOrNull(ContactsContract.Contacts._ID) ?: return@use
val nameColumn = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME) val nameColumn = cursor.getColumnIndexOrNull(ContactsContract.Contacts.DISPLAY_NAME) ?: return@use
val photoUriColumn = cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_URI) val photoUriColumn = cursor.getColumnIndexOrNull(ContactsContract.Contacts.PHOTO_URI) ?: return@use
val contactId = cursor.getInt(idColumn) val contactId = cursor.getInt(idColumn)
var name = cursor.getString(nameColumn) var name = cursor.getString(nameColumn)
@ -72,10 +73,13 @@ class ContactPicker : Picker<MultiPickerContactType>() {
selection, selection,
selectionArgs, selectionArgs,
null null
)?.use { cursor -> )?.use inner@{ innerCursor ->
while (cursor.moveToNext()) { val mimeTypeColumnIndex = innerCursor.getColumnIndexOrNull(ContactsContract.Data.MIMETYPE) ?: return@inner
val mimeType = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.MIMETYPE)) val data1ColumnIndex = innerCursor.getColumnIndexOrNull(ContactsContract.Data.DATA1) ?: return@inner
val contactData = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.DATA1))
while (innerCursor.moveToNext()) {
val mimeType = innerCursor.getString(mimeTypeColumnIndex)
val contactData = innerCursor.getString(data1ColumnIndex)
if (mimeType == ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) { if (mimeType == ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) {
name = contactData name = contactData
@ -115,7 +119,10 @@ class ContactPicker : Picker<MultiPickerContactType>() {
selectionArgs, selectionArgs,
null null
)?.use { cursor -> )?.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 android.provider.OpenableColumns
import im.vector.lib.multipicker.entity.MultiPickerBaseType import im.vector.lib.multipicker.entity.MultiPickerBaseType
import im.vector.lib.multipicker.entity.MultiPickerFileType 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.isMimeTypeAudio
import im.vector.lib.multipicker.utils.isMimeTypeImage import im.vector.lib.multipicker.utils.isMimeTypeImage
import im.vector.lib.multipicker.utils.isMimeTypeVideo import im.vector.lib.multipicker.utils.isMimeTypeVideo
@ -49,8 +50,8 @@ class FilePicker : Picker<MultiPickerBaseType>() {
// Other files // Other files
context.contentResolver.query(selectedUri, null, null, null, null) context.contentResolver.query(selectedUri, null, null, null, null)
?.use { cursor -> ?.use { cursor ->
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameColumn = cursor.getColumnIndexOrNull(OpenableColumns.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE) val sizeColumn = cursor.getColumnIndexOrNull(OpenableColumns.SIZE) ?: return@use null
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val name = cursor.getString(nameColumn) val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn) val size = cursor.getLong(sizeColumn)

View file

@ -37,8 +37,8 @@ internal fun Uri.toMultiPickerImageType(context: Context): MultiPickerImageType?
null, null,
null null
)?.use { cursor -> )?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) val nameColumn = cursor.getColumnIndexOrNull(MediaStore.Images.Media.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE) val sizeColumn = cursor.getColumnIndexOrNull(MediaStore.Images.Media.SIZE) ?: return@use null
if (cursor.moveToNext()) { if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn) val name = cursor.getString(nameColumn)
@ -75,8 +75,8 @@ internal fun Uri.toMultiPickerVideoType(context: Context): MultiPickerVideoType?
null, null,
null null
)?.use { cursor -> )?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME) val nameColumn = cursor.getColumnIndexOrNull(MediaStore.Video.Media.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE) val sizeColumn = cursor.getColumnIndexOrNull(MediaStore.Video.Media.SIZE) ?: return@use null
if (cursor.moveToNext()) { if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn) val name = cursor.getString(nameColumn)
@ -124,8 +124,8 @@ fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType? {
null, null,
null null
)?.use { cursor -> )?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME) val nameColumn = cursor.getColumnIndexOrNull(MediaStore.Audio.Media.DISPLAY_NAME) ?: return@use null
val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE) val sizeColumn = cursor.getColumnIndexOrNull(MediaStore.Audio.Media.SIZE) ?: return@use null
if (cursor.moveToNext()) { if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn) 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", "call",
"hand", "hand",
"hands", "hands",
"gesture" "gesture",
"shaka"
] ]
}, },
"backhand-index-pointing-left": { "backhand-index-pointing-left": {

View file

@ -17,7 +17,7 @@ PARAM_KEYSTORE_PATH=$1
PARAM_APK=$2 PARAM_APK=$2
# Other params # Other params
BUILD_TOOLS_VERSION="30.0.3" BUILD_TOOLS_VERSION="31.0.0-rc5"
MIN_SDK_VERSION=21 MIN_SDK_VERSION=21
echo "Signing APK with build-tools version ${BUILD_TOOLS_VERSION} for min SDK version ${MIN_SDK_VERSION}..." 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 PARAM_KEY_PASS=$4
# Other params # Other params
BUILD_TOOLS_VERSION="30.0.3" BUILD_TOOLS_VERSION="31.0.0-rc5"
MIN_SDK_VERSION=21 MIN_SDK_VERSION=21
echo "Signing APK with build-tools version ${BUILD_TOOLS_VERSION} for min SDK version ${MIN_SDK_VERSION}..." 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. // This property does not affect tests that you run using Android Studio.
animationsDisabled = true animationsDisabled = true
// Comment to run on Android 12
execution 'ANDROIDX_TEST_ORCHESTRATOR' execution 'ANDROIDX_TEST_ORCHESTRATOR'
} }
@ -356,8 +357,10 @@ dependencies {
implementation libs.squareup.moshi implementation libs.squareup.moshi
kapt libs.squareup.moshiKotlin kapt libs.squareup.moshiKotlin
implementation libs.androidx.lifecycleExtensions
// Lifecycle
implementation libs.androidx.lifecycleLivedata implementation libs.androidx.lifecycleLivedata
implementation libs.androidx.lifecycleProcess
implementation libs.androidx.datastore implementation libs.androidx.datastore
implementation libs.androidx.datastorepreferences implementation libs.androidx.datastorepreferences
@ -370,7 +373,7 @@ dependencies {
implementation 'com.facebook.stetho:stetho:1.6.0' implementation 'com.facebook.stetho:stetho:1.6.0'
// Phone number https://github.com/google/libphonenumber // Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.36' implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.37'
// FlowBinding // FlowBinding
implementation libs.github.flowBinding implementation libs.github.flowBinding
@ -411,7 +414,7 @@ dependencies {
implementation 'com.github.Armen101:AudioRecordView:1.0.5' implementation 'com.github.Armen101:AudioRecordView:1.0.5'
// Custom Tab // Custom Tab
implementation 'androidx.browser:browser:1.3.0' implementation 'androidx.browser:browser:1.4.0'
// Passphrase strength helper // Passphrase strength helper
implementation 'com.nulab-inc:zxcvbn:1.5.2' implementation 'com.nulab-inc:zxcvbn:1.5.2'

View file

@ -19,6 +19,7 @@ package im.vector.app
import android.app.Activity import android.app.Activity
import android.view.View import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso
import androidx.test.espresso.IdlingRegistry 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.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage import androidx.test.runner.lifecycle.Stage
import com.adevinta.android.barista.interaction.BaristaClickInteractions 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.Matcher
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.hamcrest.StringDescription import org.hamcrest.StringDescription
@ -52,6 +58,18 @@ object EspressoHelper {
} }
return currentActivity 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 { 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() block()
Espresso.pressBack() 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.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest 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.features.MainActivity
import im.vector.app.getString
import im.vector.app.ui.robot.ElementRobot import im.vector.app.ui.robot.ElementRobot
import im.vector.app.ui.robot.withDeveloperMode
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.UUID import java.util.UUID
@ -34,7 +39,9 @@ import java.util.UUID
class UiAllScreensSanityTest { class UiAllScreensSanityTest {
@get:Rule @get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java) val testRule = RuleChain
.outerRule(ActivityScenarioRule(MainActivity::class.java))
.around(ScreenshotFailureRule())
private val elementRobot = ElementRobot() private val elementRobot = ElementRobot()
@ -69,13 +76,30 @@ class UiAllScreensSanityTest {
createNewRoom { createNewRoom {
crawl() crawl()
createRoom { createRoom {
postMessage("Hello world!") val message = "Hello world!"
postMessage(message)
crawl() crawl()
crawlMessage(message)
openSettings { crawl() } 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 { elementRobot.roomList {
verifyCreatedRoom() verifyCreatedRoom()
} }

View file

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

View file

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

View file

@ -17,8 +17,11 @@
package im.vector.app.ui.robot.settings package im.vector.app.ui.robot.settings
import androidx.test.espresso.Espresso.pressBack 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.R
import im.vector.app.espresso.tools.clickOnPreference import im.vector.app.espresso.tools.clickOnPreference
import im.vector.app.espresso.tools.waitUntilViewVisible
class SettingsAdvancedRobot { class SettingsAdvancedRobot {
@ -28,20 +31,19 @@ class SettingsAdvancedRobot {
clickOnPreference(R.string.settings_push_rules) clickOnPreference(R.string.settings_push_rules)
pressBack() pressBack()
}
/* TODO P2 test developer screens fun toggleDeveloperMode() {
// Enable developer mode clickOn(R.string.settings_developer_mode_summary)
clickOnSwitchPreference("SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY") }
clickOnPreference(R.string.settings_account_data) fun crawlDeveloperOptions() {
clickOn("m.push_rules") clickOnPreference(R.string.settings_account_data)
pressBack() waitUntilViewVisible(withText("m.push_rules"))
pressBack() clickOn("m.push_rules")
clickOnPreference(R.string.settings_key_requests) pressBack()
pressBack() pressBack()
clickOnPreference(R.string.settings_key_requests)
// Disable developer mode pressBack()
clickOnSwitchPreference("SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY")
*/
} }
} }

View file

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

View file

@ -14,7 +14,9 @@
<application> <application>
<receiver android:name=".fdroid.receiver.OnApplicationUpgradeOrRebootReceiver"> <receiver
android:name=".fdroid.receiver.OnApplicationUpgradeOrRebootReceiver"
android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.BOOT_COMPLETED" /> <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.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.extensions.singletonEntryPoint
import im.vector.app.core.platform.PendingIntentCompat
import im.vector.app.core.services.VectorSyncService import im.vector.app.core.services.VectorSyncService
import org.matrix.android.sdk.internal.session.sync.job.SyncService import org.matrix.android.sdk.internal.session.sync.job.SyncService
import timber.log.Timber import timber.log.Timber
@ -67,7 +68,12 @@ class AlarmSyncBroadcastReceiver : BroadcastReceiver() {
putExtra(SyncService.EXTRA_SESSION_ID, sessionId) putExtra(SyncService.EXTRA_SESSION_ID, sessionId)
putExtra(SyncService.EXTRA_PERIODIC, true) 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 firstMillis = System.currentTimeMillis() + delayInSeconds * 1000L
val alarmMgr = context.getSystemService<AlarmManager>()!! val alarmMgr = context.getSystemService<AlarmManager>()!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@ -80,7 +86,12 @@ class AlarmSyncBroadcastReceiver : BroadcastReceiver() {
fun cancelAlarm(context: Context) { fun cancelAlarm(context: Context) {
Timber.v("## Sync: Cancel alarm for background sync") Timber.v("## Sync: Cancel alarm for background sync")
val intent = Intent(context, AlarmSyncBroadcastReceiver::class.java) 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>()!! val alarmMgr = context.getSystemService<AlarmManager>()!!
alarmMgr.cancel(pIntent) alarmMgr.cancel(pIntent)

View file

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

View file

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

View file

@ -4,7 +4,10 @@
package="im.vector.app"> package="im.vector.app">
<!-- Needed for VOIP call to detect and switch to headset--> <!-- 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.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -418,6 +421,22 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/sdk_provider_paths" /> android:resource="@xml/sdk_provider_paths" />
</provider> </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> </application>
</manifest> </manifest>

View file

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

View file

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

View file

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

View file

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

View file

@ -18,58 +18,56 @@ package im.vector.app.core.platform
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
fun <T> LifecycleOwner.lifecycleAwareLazy(initializer: () -> T): Lazy<T> = LifecycleAwareLazy(this, initializer) fun <T> LifecycleOwner.lifecycleAwareLazy(initializer: () -> T): Lazy<T> = LifecycleAwareLazy(this, initializer)
private object UninitializedValue private object UninitializedValue
class LifecycleAwareLazy<out T>( class LifecycleAwareLazy<out T>(
private val owner: LifecycleOwner, private val owner: LifecycleOwner,
initializer: () -> T initializer: () -> T
) : Lazy<T>, LifecycleObserver { ) : Lazy<T>, DefaultLifecycleObserver {
private var initializer: (() -> T)? = initializer private var initializer: (() -> T)? = initializer
private var _value: Any? = UninitializedValue private var _value: Any? = UninitializedValue
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override val value: T override val value: T
@MainThread @MainThread
get() { get() {
if (_value === UninitializedValue) { if (_value === UninitializedValue) {
_value = initializer!!() _value = initializer!!()
attachToLifecycle() attachToLifecycle()
} }
return _value as T return _value as T
}
override fun onDestroy(owner: LifecycleOwner) {
_value = UninitializedValue
detachFromLifecycle()
} }
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) private fun attachToLifecycle() {
fun resetValue() { if (getLifecycleOwner().lifecycle.currentState == Lifecycle.State.DESTROYED) {
_value = UninitializedValue throw IllegalStateException("Initialization failed because lifecycle has been destroyed!")
detachFromLifecycle() }
} getLifecycleOwner().lifecycle.addObserver(this)
private fun attachToLifecycle() {
if (getLifecycleOwner().lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Initialization failed because lifecycle has been destroyed!")
} }
getLifecycleOwner().lifecycle.addObserver(this)
}
private fun detachFromLifecycle() { private fun detachFromLifecycle() {
getLifecycleOwner().lifecycle.removeObserver(this) getLifecycleOwner().lifecycle.removeObserver(this)
} }
private fun getLifecycleOwner() = when (owner) { private fun getLifecycleOwner() = when (owner) {
is Fragment -> owner.viewLifecycleOwner is Fragment -> owner.viewLifecycleOwner
else -> owner else -> owner
} }
override fun isInitialized(): Boolean = _value !== UninitializedValue override fun isInitialized(): Boolean = _value !== UninitializedValue
override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet." override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."
} }

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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,17 +14,21 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.call.telecom package im.vector.app.core.platform
import android.content.Context import android.app.PendingIntent
import android.telephony.TelephonyManager import android.os.Build
import androidx.core.content.getSystemService
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 FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val telephonyManager = context.getSystemService<TelephonyManager>() PendingIntent.FLAG_MUTABLE
?: return false } else {
return telephonyManager.callState != TelephonyManager.CALL_STATE_IDLE 0
} }
} }

View file

@ -16,6 +16,7 @@
package im.vector.app.core.platform package im.vector.app.core.platform
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.res.Configuration 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 // New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
window.setDecorFitsSystemWindows(false) window.setDecorFitsSystemWindows(false)
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE // New API instead of SYSTEM_UI_FLAG_IMMERSIVE
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE 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 // New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, im.vector.lib.attachmentviewer.R.color.half_transparent_status_bar) window.statusBarColor = ContextCompat.getColor(this, im.vector.lib.attachmentviewer.R.color.half_transparent_status_bar)
// New API instead of FLAG_TRANSLUCENT_NAVIGATION // New API instead of FLAG_TRANSLUCENT_NAVIGATION

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,8 +21,10 @@ import android.content.pm.ShortcutManager
import android.os.Build import android.os.Build
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.dispatchers.CoroutineDispatchers 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.PinCodeStore
import im.vector.app.features.pin.PinCodeStoreListener import im.vector.app.features.pin.PinCodeStoreListener
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -43,6 +45,7 @@ import javax.inject.Inject
class ShortcutsHandler @Inject constructor( class ShortcutsHandler @Inject constructor(
private val context: Context, private val context: Context,
private val stringProvider: StringProvider,
private val appDispatchers: CoroutineDispatchers, private val appDispatchers: CoroutineDispatchers,
private val shortcutCreator: ShortcutCreator, private val shortcutCreator: ShortcutCreator,
private val activeSessionHolder: ActiveSessionHolder, private val activeSessionHolder: ActiveSessionHolder,
@ -72,7 +75,7 @@ class ShortcutsHandler @Inject constructor(
.onCompletion { pinCodeStore.removeListener(this@ShortcutsHandler) } .onCompletion { pinCodeStore.removeListener(this@ShortcutsHandler) }
.onEach { rooms -> .onEach { rooms ->
// Remove dead shortcuts (i.e. deleted rooms) // Remove dead shortcuts (i.e. deleted rooms)
removeDeadShortcut(rooms.map { it.roomId }) removeDeadShortcuts(rooms.map { it.roomId })
// Create shortcuts // Create shortcuts
createShortcuts(rooms) createShortcuts(rooms)
@ -81,7 +84,7 @@ class ShortcutsHandler @Inject constructor(
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
private fun removeDeadShortcut(roomIds: List<String>) { private fun removeDeadShortcuts(roomIds: List<String>) {
val deadShortcutIds = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC) val deadShortcutIds = ShortcutManagerCompat.getShortcuts(context, ShortcutManagerCompat.FLAG_MATCH_DYNAMIC)
.map { it.id } .map { it.id }
.filter { !roomIds.contains(it) } .filter { !roomIds.contains(it) }
@ -91,7 +94,11 @@ class ShortcutsHandler @Inject constructor(
ShortcutManagerCompat.removeLongLivedShortcuts(context, deadShortcutIds) ShortcutManagerCompat.removeLongLivedShortcuts(context, deadShortcutIds)
if (isRequestPinShortcutSupported) { if (isRequestPinShortcutSupported) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { 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 (isRequestPinShortcutSupported) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
context.getSystemService<ShortcutManager>() context.getSystemService<ShortcutManager>()
?.let { ?.pinnedShortcuts
it.disableShortcuts(it.pinnedShortcuts.map { pinnedShortcut -> pinnedShortcut.id }) ?.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 // VectorInviteView.Callback
override fun onAcceptInvite() { override fun onAcceptInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) }
roomDetailViewModel.handle(RoomDetailAction.AcceptInvite) roomDetailViewModel.handle(RoomDetailAction.AcceptInvite)
} }
override fun onRejectInvite() { override fun onRejectInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) }
roomDetailViewModel.handle(RoomDetailAction.RejectInvite) roomDetailViewModel.handle(RoomDetailAction.RejectInvite)
} }

View file

@ -17,13 +17,15 @@
package im.vector.app.features.home.room.detail.composer package im.vector.app.features.home.room.detail.composer
import android.content.ClipData
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.text.Editable import android.text.Editable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection 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.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import com.vanniktech.emoji.EmojiEditText import com.vanniktech.emoji.EmojiEditText
@ -33,7 +35,7 @@ import im.vector.app.features.html.PillImageSpan
import timber.log.Timber import timber.log.Timber
class ComposerEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle) : class ComposerEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle) :
EmojiEditText(context, attrs, defStyleAttr) { EmojiEditText(context, attrs, defStyleAttr) {
interface Callback { interface Callback {
fun onRichContentSelected(contentUri: Uri): Boolean fun onRichContentSelected(contentUri: Uri): Boolean
@ -43,23 +45,35 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
var callback: Callback? = null var callback: Callback? = null
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? { override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? {
val ic = super.onCreateInputConnection(editorInfo) ?: return null var ic = super.onCreateInputConnection(editorInfo) ?: return null
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("*/*")) val mimeTypes = ViewCompat.getOnReceiveContentMimeTypes(this) ?: arrayOf("image/*")
val callback = EditorInfoCompat.setContentMimeTypes(editorInfo, mimeTypes)
InputConnectionCompat.OnCommitContentListener { inputContentInfo, flags, _ -> ic = InputConnectionCompat.createWrapper(this, ic, editorInfo)
val lacksPermission = (flags and
InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0 val onReceiveContentListener = OnReceiveContentListener { _, payload ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && lacksPermission) { val split = payload.partition { item -> item.uri != null }
try { val uriContent = split.first
inputContentInfo.requestPermission() val remaining = split.second
} catch (e: Exception) {
return@OnCommitContentListener false if (uriContent != null) {
} val clip: ClipData = uriContent.clip
} for (i in 0 until clip.itemCount) {
callback?.onRichContentSelected(inputContentInfo.contentUri) ?: false val uri = clip.getItemAt(i).uri
// ... app-specific logic to handle the URI ...
callback?.onRichContentSelected(uri)
} }
return InputConnectionCompat.createWrapper(ic, editorInfo, callback) }
// 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
}
ViewCompat.setOnReceiveContentListener(this, mimeTypes, onReceiveContentListener)
return ic
} }
init { init {

View file

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

View file

@ -15,8 +15,10 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import android.net.Uri
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.takeAs
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.displayname.getBestName import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter 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.Event
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isEdition 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.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership 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.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.sender.SenderInfo
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getEditedEventId 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.api.util.toMatrixItem
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import timber.log.Timber import timber.log.Timber
@ -49,11 +54,12 @@ import javax.inject.Inject
class NotifiableEventResolver @Inject constructor( class NotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val noticeEventFormatter: NoticeEventFormatter, private val noticeEventFormatter: NoticeEventFormatter,
private val displayableEventFormatter: DisplayableEventFormatter) { private val displayableEventFormatter: DisplayableEventFormatter
) {
// private val eventDisplay = RiotEventDisplay(context) // 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 roomID = event.roomId ?: return null
val eventId = event.eventId ?: return null val eventId = event.eventId ?: return null
if (event.getClearType() == EventType.STATE_ROOM_MEMBER) { 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 if (event.getClearType() != EventType.MESSAGE) return null
// Ignore message edition // 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...) // 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*/) val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/)
@ -140,6 +146,7 @@ class NotifiableEventResolver @Inject constructor(
senderName = senderDisplayName, senderName = senderDisplayName,
senderId = event.root.senderId, senderId = event.root.senderId,
body = body.toString(), body = body.toString(),
imageUri = event.fetchImageIfPresent(session),
roomId = event.root.roomId!!, roomId = event.root.roomId!!,
roomName = roomName, roomName = roomName,
matrixID = session.myUserId matrixID = session.myUserId
@ -173,6 +180,7 @@ class NotifiableEventResolver @Inject constructor(
senderName = senderDisplayName, senderName = senderDisplayName,
senderId = event.root.senderId, senderId = event.root.senderId,
body = body, body = body,
imageUri = event.fetchImageIfPresent(session),
roomId = event.root.roomId!!, roomId = event.root.roomId!!,
roomName = roomName, roomName = roomName,
roomIsDirect = room.roomSummary()?.isDirect ?: false, 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? { private fun resolveStateRoomEvent(event: Event, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? {
val content = event.content?.toModel<RoomMemberContent>() ?: return null val content = event.content?.toModel<RoomMemberContent>() ?: return null
val roomId = event.roomId ?: return null val roomId = event.roomId ?: return null

View file

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

View file

@ -49,26 +49,26 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
NotificationUtils.SMART_REPLY_ACTION -> NotificationUtils.SMART_REPLY_ACTION ->
handleSmartReply(intent, context) handleSmartReply(intent, context)
NotificationUtils.DISMISS_ROOM_NOTIF_ACTION -> NotificationUtils.DISMISS_ROOM_NOTIF_ACTION ->
intent.getStringExtra(KEY_ROOM_ID)?.let { intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.clearMessageEventOfRoom(it) notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
} }
NotificationUtils.DISMISS_SUMMARY_ACTION -> NotificationUtils.DISMISS_SUMMARY_ACTION ->
notificationDrawerManager.clearAllEvents() notificationDrawerManager.clearAllEvents()
NotificationUtils.MARK_ROOM_READ_ACTION -> NotificationUtils.MARK_ROOM_READ_ACTION ->
intent.getStringExtra(KEY_ROOM_ID)?.let { intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.clearMessageEventOfRoom(it) notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
handleMarkAsRead(it) handleMarkAsRead(roomId)
} }
NotificationUtils.JOIN_ACTION -> { NotificationUtils.JOIN_ACTION -> {
intent.getStringExtra(KEY_ROOM_ID)?.let { intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.clearMemberShipNotificationForRoom(it) notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleJoinRoom(it) handleJoinRoom(roomId)
} }
} }
NotificationUtils.REJECT_ACTION -> { NotificationUtils.REJECT_ACTION -> {
intent.getStringExtra(KEY_ROOM_ID)?.let { intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.clearMemberShipNotificationForRoom(it) notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleRejectRoom(it) handleRejectRoom(roomId)
} }
} }
} }
@ -138,6 +138,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
?: context?.getString(R.string.notification_sender_me), ?: context?.getString(R.string.notification_sender_me),
senderId = session.myUserId, senderId = session.myUserId,
body = message, body = message,
imageUri = null,
roomId = room.roomId, roomId = room.roomId,
roomName = room.roomSummary()?.displayName ?: room.roomId, roomName = room.roomSummary()?.displayName ?: room.roomId,
roomIsDirect = room.roomSummary()?.isDirect == true, roomIsDirect = room.roomSummary()?.isDirect == true,
@ -145,8 +146,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
canBeReplaced = false canBeReplaced = false
) )
notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) }
notificationDrawerManager.refreshNotificationDrawer()
/* /*
// TODO Error cannot be managed the same way than in Riot // 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. #refreshNotificationDrawer() is called.
Events might be grouped and there might not be one notification per event! 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()) { if (!vectorPreferences.areNotificationEnabledForDevice()) {
Timber.i("Notification are disabled for this device") Timber.i("Notification are disabled for this device")
return return
@ -105,87 +105,15 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
} else { } else {
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") 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) { add(notifiableEvent, seenEventIds)
// 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)
}
}
}
} }
/** /**
* Clear all known events and refresh the notification drawer * Clear all known events and refresh the notification drawer
*/ */
fun clearAllEvents() { fun clearAllEvents() {
synchronized(queuedEvents) { updateEvents { it.clear() }
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()
}
}
} }
/** /**
@ -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. * 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?) { fun setCurrentRoom(roomId: String?) {
var hasChanged: Boolean updateEvents {
synchronized(queuedEvents) { val hasChanged = roomId != currentRoomId
hasChanged = roomId != currentRoomId
currentRoomId = roomId currentRoomId = roomId
} if (hasChanged && roomId != null) {
if (hasChanged) { it.clearMessagesForRoom(roomId)
clearMessageEventOfRoom(roomId) }
} }
} }
fun clearMemberShipNotificationForRoom(roomId: String) { fun notificationStyleChanged() {
val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId } updateEvents {
if (shouldUpdate) { val newSettings = vectorPreferences.useCompleteNotificationFormat()
refreshNotificationDrawerBg() if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications
notificationDisplayer.cancelAllNotifications()
useCompleteNotificationFormat = newSettings
}
} }
} }
private fun removeAll(predicate: (NotifiableEvent) -> Boolean): Boolean { fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
return synchronized(queuedEvents) { synchronized(queuedEvents) {
queuedEvents.removeAll(predicate) action(this, queuedEvents)
} }
refreshNotificationDrawer()
} }
private var firstThrottler = FirstThrottler(200) private var firstThrottler = FirstThrottler(200)
fun refreshNotificationDrawer() { private fun refreshNotificationDrawer() {
// Implement last throttler // Implement last throttler
val canHandle = firstThrottler.canHandle() val canHandle = firstThrottler.canHandle()
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms") Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
@ -239,18 +171,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
@WorkerThread @WorkerThread
private fun refreshNotificationDrawerBg() { private fun refreshNotificationDrawerBg() {
Timber.v("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) { val eventsToRender = synchronized(queuedEvents) {
notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also { notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, renderedEvents).also {
queuedEvents.clear() queuedEvents.clearAndAdd(it.onlyKeptEvents())
queuedEvents.addAll(it.onlyKeptEvents())
} }
} }
@ -286,7 +209,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile() if (!file.exists()) file.createNewFile()
FileOutputStream(file).use { FileOutputStream(file).use {
currentSession?.securelyStoreObject(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it) currentSession?.securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info") 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 { try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) { if (file.exists()) {
file.inputStream().use { file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = currentSession?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE) val events: ArrayList<NotifiableEvent>? = currentSession?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) { if (events != null) {
return events.toMutableList() return NotificationEventQueue(events.toMutableList())
} }
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Timber.e(e, "## Failed to load cached notification info") Timber.e(e, "## Failed to load cached notification info")
} }
return ArrayList() return NotificationEventQueue()
} }
private fun deleteCachedRoomNotifications() { private fun deleteCachedRoomNotifications() {
@ -330,11 +253,3 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr" 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 package im.vector.app.features.notifications
import android.app.Notification import android.app.Notification
import androidx.core.content.pm.ShortcutInfoCompat
import javax.inject.Inject import javax.inject.Inject
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>> private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
@ -104,7 +103,7 @@ class NotificationFactory @Inject constructor(
sealed interface RoomNotification { sealed interface RoomNotification {
data class Removed(val roomId: String) : 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( data class Meta(
val summaryLine: CharSequence, val summaryLine: CharSequence,
val messageCount: Int, val messageCount: Int,

View file

@ -17,7 +17,6 @@ package im.vector.app.features.notifications
import android.content.Context import android.content.Context
import androidx.annotation.WorkerThread 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_EVENT_NOTIFICATION_ID
import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_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 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) { is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.d("Updating room messages notification ${wrapper.meta.roomId}") 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) 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.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.createIgnoredUri 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.resources.StringProvider
import im.vector.app.core.services.CallService import im.vector.app.core.services.CallService
import im.vector.app.core.utils.startNotificationChannelSettingsIntent 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. // build the pending intent go to the home screen if this is clicked.
val i = HomeActivity.newIntent(context) val i = HomeActivity.newIntent(context)
i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP 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) 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 flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
data = createIgnoredUri(call.callId) 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) val answerCallPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context)) .addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(VectorCallActivity.newIntent( .addNextIntent(
context = context, VectorCallActivity.newIntent(
call = call, context = context,
mode = VectorCallActivity.INCOMING_ACCEPT) call = call,
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) 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) IconCompat.createWithResource(context, R.drawable.ic_call_hangup)
.setTint(ThemeUtils.getColor(context, R.attr.colorError)), .setTint(ThemeUtils.getColor(context, R.attr.colorError)),
getActionText(R.string.call_notification_reject, R.attr.colorError), getActionText(R.string.call_notification_reject, R.attr.colorError),
rejectCallPendingIntent) rejectCallPendingIntent
)
) )
builder.addAction( 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 flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
data = createIgnoredUri(call.callId) 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) 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) IconCompat.createWithResource(context, R.drawable.ic_call_hangup)
.setTint(ThemeUtils.getColor(context, R.attr.colorError)), .setTint(ThemeUtils.getColor(context, R.attr.colorError)),
getActionText(R.string.call_notification_hangup, R.attr.colorError), getActionText(R.string.call_notification_hangup, R.attr.colorError),
rejectCallPendingIntent) rejectCallPendingIntent
)
) )
builder.setContentIntent(contentPendingIntent) builder.setContentIntent(contentPendingIntent)
@ -431,13 +446,14 @@ class NotificationUtils @Inject constructor(private val context: Context,
IconCompat.createWithResource(context, R.drawable.ic_call_hangup) IconCompat.createWithResource(context, R.drawable.ic_call_hangup)
.setTint(ThemeUtils.getColor(context, R.attr.colorError)), .setTint(ThemeUtils.getColor(context, R.attr.colorError)),
getActionText(R.string.call_notification_hangup, R.attr.colorError), getActionText(R.string.call_notification_hangup, R.attr.colorError),
rejectCallPendingIntent) rejectCallPendingIntent
)
) )
val contentPendingIntent = TaskStackBuilder.create(context) val contentPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context)) .addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(VectorCallActivity.newIntent(context, call, null)) .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) builder.setContentIntent(contentPendingIntent)
@ -453,7 +469,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
context, context,
System.currentTimeMillis().toInt(), System.currentTimeMillis().toInt(),
rejectCallActionReceiver, 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) val contentPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context)) .addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(RoomDetailActivity.newIntent(context, RoomDetailArgs(callInformation.nativeRoomId))) .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) builder.setContentIntent(contentPendingIntent)
return builder.build() return builder.build()
@ -517,7 +533,10 @@ class NotificationUtils @Inject constructor(private val context: Context,
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
PendingIntent.getActivity( 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 { ).let {
setContentIntent(it) setContentIntent(it)
} }
@ -587,8 +606,12 @@ class NotificationUtils @Inject constructor(private val context: Context,
markRoomReadIntent.action = MARK_ROOM_READ_ACTION markRoomReadIntent.action = MARK_ROOM_READ_ACTION
markRoomReadIntent.data = createIgnoredUri(roomInfo.roomId) markRoomReadIntent.data = createIgnoredUri(roomInfo.roomId)
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
val markRoomReadPendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), markRoomReadIntent, val markRoomReadPendingIntent = PendingIntent.getBroadcast(
PendingIntent.FLAG_UPDATE_CURRENT) context,
System.currentTimeMillis().toInt(),
markRoomReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
NotificationCompat.Action.Builder(R.drawable.ic_material_done_all_white, NotificationCompat.Action.Builder(R.drawable.ic_material_done_all_white,
stringProvider.getString(R.string.action_mark_room_read), markRoomReadPendingIntent) 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) val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
intent.action = DISMISS_ROOM_NOTIF_ACTION intent.action = DISMISS_ROOM_NOTIF_ACTION
val pendingIntent = PendingIntent.getBroadcast(context.applicationContext, val pendingIntent = PendingIntent.getBroadcast(
System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT) context.applicationContext,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
setDeleteIntent(pendingIntent) setDeleteIntent(pendingIntent)
} }
.setTicker(tickerText) .setTicker(tickerText)
@ -655,31 +682,41 @@ class NotificationUtils @Inject constructor(private val context: Context,
rejectIntent.action = REJECT_ACTION rejectIntent.action = REJECT_ACTION
rejectIntent.data = createIgnoredUri("$roomId&$matrixId") rejectIntent.data = createIgnoredUri("$roomId&$matrixId")
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val rejectIntentPendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), rejectIntent, val rejectIntentPendingIntent = PendingIntent.getBroadcast(
PendingIntent.FLAG_UPDATE_CURRENT) context,
System.currentTimeMillis().toInt(),
rejectIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
addAction( addAction(
R.drawable.vector_notification_reject_invitation, R.drawable.vector_notification_reject_invitation,
stringProvider.getString(R.string.reject), stringProvider.getString(R.string.reject),
rejectIntentPendingIntent) rejectIntentPendingIntent
)
// offer to type a quick accept button // offer to type a quick accept button
val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java) val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java)
joinIntent.action = JOIN_ACTION joinIntent.action = JOIN_ACTION
joinIntent.data = createIgnoredUri("$roomId&$matrixId") joinIntent.data = createIgnoredUri("$roomId&$matrixId")
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val joinIntentPendingIntent = PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), joinIntent, val joinIntentPendingIntent = PendingIntent.getBroadcast(
PendingIntent.FLAG_UPDATE_CURRENT) context,
System.currentTimeMillis().toInt(),
joinIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
addAction( addAction(
R.drawable.vector_notification_accept_invitation, R.drawable.vector_notification_accept_invitation,
stringProvider.getString(R.string.join), stringProvider.getString(R.string.join),
joinIntentPendingIntent) joinIntentPendingIntent
)
val contentIntent = HomeActivity.newIntent(context, inviteNotificationRoomId = inviteNotifiableEvent.roomId) val contentIntent = HomeActivity.newIntent(context, inviteNotificationRoomId = inviteNotifiableEvent.roomId)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP 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 // 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) contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, 0)) setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
if (inviteNotifiableEvent.noisy) { if (inviteNotifiableEvent.noisy) {
// Compat // 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 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 // 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) contentIntent.data = createIgnoredUri(simpleNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, 0)) setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
if (simpleNotifiableEvent.noisy) { if (simpleNotifiableEvent.noisy) {
// Compat // Compat
@ -745,14 +782,22 @@ class NotificationUtils @Inject constructor(private val context: Context,
return TaskStackBuilder.create(context) return TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context)) .addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(roomIntentTap) .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 { private fun buildOpenHomePendingIntentForSummary(): PendingIntent {
val intent = HomeActivity.newIntent(context, clearNotification = true) val intent = HomeActivity.newIntent(context, clearNotification = true)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.data = createIgnoredUri("tapSummary") 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.action = SMART_REPLY_ACTION
intent.data = createIgnoredUri(roomId) intent.data = createIgnoredUri(roomId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
return PendingIntent.getBroadcast(context, System.currentTimeMillis().toInt(), intent, return PendingIntent.getBroadcast(
PendingIntent.FLAG_UPDATE_CURRENT) context,
System.currentTimeMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
} else { } else {
/* /*
TODO TODO
@ -783,7 +833,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
// the action must be unique else the parameters are ignored // the action must be unique else the parameters are ignored
quickReplyIntent.action = QUICK_LAUNCH_ACTION quickReplyIntent.action = QUICK_LAUNCH_ACTION
quickReplyIntent.data = createIgnoredUri($roomId") 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) val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = DISMISS_SUMMARY_ACTION intent.action = DISMISS_SUMMARY_ACTION
intent.data = createIgnoredUri("deleteSummary") intent.data = createIgnoredUri("deleteSummary")
return PendingIntent.getBroadcast(context.applicationContext, return PendingIntent.getBroadcast(
0, intent, PendingIntent.FLAG_UPDATE_CURRENT) context.applicationContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
} }
fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
@ -875,7 +929,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
context, context,
0, 0,
testActionIntent, testActionIntent,
PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
) )
notificationManager.notify( notificationManager.notify(

View file

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

View file

@ -18,14 +18,10 @@ package im.vector.app.features.notifications
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person 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.R
import im.vector.app.core.resources.StringProvider 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 me.gujun.android.span.span import me.gujun.android.span.span
import timber.log.Timber import timber.log.Timber
@ -61,17 +57,6 @@ class RoomGroupMessageCreator @Inject constructor(
} }
val largeBitmap = getRoomBitmap(events) 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 lastMessageTimestamp = events.last().timestamp
val smartReplyErrors = events.filter { it.isSmartReplyError() } val smartReplyErrors = events.filter { it.isSmartReplyError() }
@ -96,7 +81,6 @@ class RoomGroupMessageCreator @Inject constructor(
userDisplayName, userDisplayName,
tickerText tickerText
), ),
shortcutInfo,
meta meta
) )
} }
@ -114,7 +98,14 @@ class RoomGroupMessageCreator @Inject constructor(
} }
when { when {
event.isSmartReplyError() -> addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson) 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 package im.vector.app.features.pin
import android.os.SystemClock import android.os.SystemClock
import androidx.lifecycle.Lifecycle import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -41,7 +40,7 @@ private const val PERIOD_OF_GRACE_IN_MS = 2 * 60 * 1000L
class PinLocker @Inject constructor( class PinLocker @Inject constructor(
private val pinCodeStore: PinCodeStore, private val pinCodeStore: PinCodeStore,
private val vectorPreferences: VectorPreferences private val vectorPreferences: VectorPreferences
) : LifecycleObserver { ) : DefaultLifecycleObserver {
enum class State { enum class State {
// App is locked, can be unlock // App is locked, can be unlock
@ -87,16 +86,14 @@ class PinLocker @Inject constructor(
computeState() computeState()
} }
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME) override fun onResume(owner: LifecycleOwner) {
fun entersForeground() {
val timeElapsedSinceBackground = SystemClock.elapsedRealtime() - entersBackgroundTs val timeElapsedSinceBackground = SystemClock.elapsedRealtime() - entersBackgroundTs
shouldBeLocked = shouldBeLocked || timeElapsedSinceBackground >= getGracePeriod() shouldBeLocked = shouldBeLocked || timeElapsedSinceBackground >= getGracePeriod()
Timber.v("App enters foreground after $timeElapsedSinceBackground ms spent in background shouldBeLocked: $shouldBeLocked") Timber.v("App enters foreground after $timeElapsedSinceBackground ms spent in background shouldBeLocked: $shouldBeLocked")
computeState() computeState()
} }
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) override fun onPause(owner: LifecycleOwner) {
fun entersBackground() {
Timber.v("App enters background") Timber.v("App enters background")
entersBackgroundTs = SystemClock.elapsedRealtime() entersBackgroundTs = SystemClock.elapsedRealtime()
} }

View file

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

View file

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

View file

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

View file

@ -23,7 +23,7 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
abstract class AbstractVoiceRecorder( abstract class AbstractVoiceRecorder(
context: Context, private val context: Context,
private val filenameExt: String private val filenameExt: String
) : VoiceRecorder { ) : VoiceRecorder {
private val outputDirectory: File by lazy { private val outputDirectory: File by lazy {
@ -39,7 +39,7 @@ abstract class AbstractVoiceRecorder(
abstract fun convertFile(recordedFile: File?): File? abstract fun convertFile(recordedFile: File?): File?
private fun init() { private fun init() {
MediaRecorder().let { createMediaRecorder().let {
it.setAudioSource(MediaRecorder.AudioSource.DEFAULT) it.setAudioSource(MediaRecorder.AudioSource.DEFAULT)
setOutputFormat(it) setOutputFormat(it)
it.setAudioEncodingBitRate(24000) 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() { override fun startRecord() {
init() init()
outputFile = File(outputDirectory, "Voice message.$filenameExt") outputFile = File(outputDirectory, "Voice message.$filenameExt")

View file

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

View file

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

View file

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

View file

@ -175,7 +175,7 @@
android:weightSum="3"> android:weightSum="3">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center" 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_forget">Forget</string>
<string name="room_settings_add_homescreen_shortcut">Add to Home screen</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 --> <!-- home sliding menu -->
<string name="room_sliding_menu_messages">Messages</string> <string name="room_sliding_menu_messages">Messages</string>
<string name="room_sliding_menu_settings">Settings</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.features.notifications.ProcessedEvent.Type
import im.vector.app.test.fakes.FakeAutoAcceptInvites import im.vector.app.test.fakes.FakeAutoAcceptInvites
import im.vector.app.test.fakes.FakeOutdatedEventDetector 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.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
@ -145,48 +148,3 @@ class NotifiableEventProcessorTest {
ProcessedEvent(it.first, it.second) 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.FakeNotificationUtils
import im.vector.app.test.fakes.FakeRoomGroupMessageCreator import im.vector.app.test.fakes.FakeRoomGroupMessageCreator
import im.vector.app.test.fakes.FakeSummaryGroupMessageCreator 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.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test

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